Monorepo for Tangled tangled.org

appview: universal background pds record migration logic #1307

open opened by boltless.me targeting master from sl/pdsmigration

This can be used for current or future pds record migrations.

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:xasnlahkri4ewmbuzly2rlc5/sh.tangled.repo.pull/3mjahdscj6d22
+3326 -1674
Diff #0
+589
api/tangled/cbor_gen.go
··· 8 8 "math" 9 9 "sort" 10 10 11 + atproto "github.com/bluesky-social/indigo/api/atproto" 11 12 util "github.com/bluesky-social/indigo/lex/util" 12 13 cid "github.com/ipfs/go-cid" 13 14 cbg "github.com/whyrusleeping/cbor-gen" ··· 648 649 t.PinnedRepositories[i] = string(sval) 649 650 } 650 651 652 + } 653 + } 654 + 655 + default: 656 + // Field doesn't exist on this type, so ignore it 657 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 658 + return err 659 + } 660 + } 661 + } 662 + 663 + return nil 664 + } 665 + func (t *FeedComment) MarshalCBOR(w io.Writer) error { 666 + if t == nil { 667 + _, err := w.Write(cbg.CborNull) 668 + return err 669 + } 670 + 671 + cw := cbg.NewCborWriter(w) 672 + fieldCount := 6 673 + 674 + if t.PullRoundIdx == nil { 675 + fieldCount-- 676 + } 677 + 678 + if t.ReplyTo == nil { 679 + fieldCount-- 680 + } 681 + 682 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 683 + return err 684 + } 685 + 686 + // t.Body (tangled.FeedComment_Body) (struct) 687 + if len("body") > 1000000 { 688 + return xerrors.Errorf("Value in field \"body\" was too long") 689 + } 690 + 691 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 692 + return err 693 + } 694 + if _, err := cw.WriteString(string("body")); err != nil { 695 + return err 696 + } 697 + 698 + if err := t.Body.MarshalCBOR(cw); err != nil { 699 + return err 700 + } 701 + 702 + // t.LexiconTypeID (string) (string) 703 + if len("$type") > 1000000 { 704 + return xerrors.Errorf("Value in field \"$type\" was too long") 705 + } 706 + 707 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 708 + return err 709 + } 710 + if _, err := cw.WriteString(string("$type")); err != nil { 711 + return err 712 + } 713 + 714 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.feed.comment"))); err != nil { 715 + return err 716 + } 717 + if _, err := cw.WriteString(string("sh.tangled.feed.comment")); err != nil { 718 + return err 719 + } 720 + 721 + // t.ReplyTo (atproto.RepoStrongRef) (struct) 722 + if t.ReplyTo != nil { 723 + 724 + if len("replyTo") > 1000000 { 725 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 726 + } 727 + 728 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 729 + return err 730 + } 731 + if _, err := cw.WriteString(string("replyTo")); err != nil { 732 + return err 733 + } 734 + 735 + if err := t.ReplyTo.MarshalCBOR(cw); err != nil { 736 + return err 737 + } 738 + } 739 + 740 + // t.Subject (atproto.RepoStrongRef) (struct) 741 + if len("subject") > 1000000 { 742 + return xerrors.Errorf("Value in field \"subject\" was too long") 743 + } 744 + 745 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 746 + return err 747 + } 748 + if _, err := cw.WriteString(string("subject")); err != nil { 749 + return err 750 + } 751 + 752 + if err := t.Subject.MarshalCBOR(cw); err != nil { 753 + return err 754 + } 755 + 756 + // t.CreatedAt (string) (string) 757 + if len("createdAt") > 1000000 { 758 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 759 + } 760 + 761 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 762 + return err 763 + } 764 + if _, err := cw.WriteString(string("createdAt")); err != nil { 765 + return err 766 + } 767 + 768 + if len(t.CreatedAt) > 1000000 { 769 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 770 + } 771 + 772 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 773 + return err 774 + } 775 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 776 + return err 777 + } 778 + 779 + // t.PullRoundIdx (int64) (int64) 780 + if t.PullRoundIdx != nil { 781 + 782 + if len("pullRoundIdx") > 1000000 { 783 + return xerrors.Errorf("Value in field \"pullRoundIdx\" was too long") 784 + } 785 + 786 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pullRoundIdx"))); err != nil { 787 + return err 788 + } 789 + if _, err := cw.WriteString(string("pullRoundIdx")); err != nil { 790 + return err 791 + } 792 + 793 + if t.PullRoundIdx == nil { 794 + if _, err := cw.Write(cbg.CborNull); err != nil { 795 + return err 796 + } 797 + } else { 798 + if *t.PullRoundIdx >= 0 { 799 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.PullRoundIdx)); err != nil { 800 + return err 801 + } 802 + } else { 803 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.PullRoundIdx-1)); err != nil { 804 + return err 805 + } 806 + } 807 + } 808 + 809 + } 810 + return nil 811 + } 812 + 813 + func (t *FeedComment) UnmarshalCBOR(r io.Reader) (err error) { 814 + *t = FeedComment{} 815 + 816 + cr := cbg.NewCborReader(r) 817 + 818 + maj, extra, err := cr.ReadHeader() 819 + if err != nil { 820 + return err 821 + } 822 + defer func() { 823 + if err == io.EOF { 824 + err = io.ErrUnexpectedEOF 825 + } 826 + }() 827 + 828 + if maj != cbg.MajMap { 829 + return fmt.Errorf("cbor input should be of type map") 830 + } 831 + 832 + if extra > cbg.MaxLength { 833 + return fmt.Errorf("FeedComment: map struct too large (%d)", extra) 834 + } 835 + 836 + n := extra 837 + 838 + nameBuf := make([]byte, 12) 839 + for i := uint64(0); i < n; i++ { 840 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 841 + if err != nil { 842 + return err 843 + } 844 + 845 + if !ok { 846 + // Field doesn't exist on this type, so ignore it 847 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 848 + return err 849 + } 850 + continue 851 + } 852 + 853 + switch string(nameBuf[:nameLen]) { 854 + // t.Body (tangled.FeedComment_Body) (struct) 855 + case "body": 856 + 857 + { 858 + 859 + b, err := cr.ReadByte() 860 + if err != nil { 861 + return err 862 + } 863 + if b != cbg.CborNull[0] { 864 + if err := cr.UnreadByte(); err != nil { 865 + return err 866 + } 867 + t.Body = new(FeedComment_Body) 868 + if err := t.Body.UnmarshalCBOR(cr); err != nil { 869 + return xerrors.Errorf("unmarshaling t.Body pointer: %w", err) 870 + } 871 + } 872 + 873 + } 874 + // t.LexiconTypeID (string) (string) 875 + case "$type": 876 + 877 + { 878 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 879 + if err != nil { 880 + return err 881 + } 882 + 883 + t.LexiconTypeID = string(sval) 884 + } 885 + // t.ReplyTo (atproto.RepoStrongRef) (struct) 886 + case "replyTo": 887 + 888 + { 889 + 890 + b, err := cr.ReadByte() 891 + if err != nil { 892 + return err 893 + } 894 + if b != cbg.CborNull[0] { 895 + if err := cr.UnreadByte(); err != nil { 896 + return err 897 + } 898 + t.ReplyTo = new(atproto.RepoStrongRef) 899 + if err := t.ReplyTo.UnmarshalCBOR(cr); err != nil { 900 + return xerrors.Errorf("unmarshaling t.ReplyTo pointer: %w", err) 901 + } 902 + } 903 + 904 + } 905 + // t.Subject (atproto.RepoStrongRef) (struct) 906 + case "subject": 907 + 908 + { 909 + 910 + b, err := cr.ReadByte() 911 + if err != nil { 912 + return err 913 + } 914 + if b != cbg.CborNull[0] { 915 + if err := cr.UnreadByte(); err != nil { 916 + return err 917 + } 918 + t.Subject = new(atproto.RepoStrongRef) 919 + if err := t.Subject.UnmarshalCBOR(cr); err != nil { 920 + return xerrors.Errorf("unmarshaling t.Subject pointer: %w", err) 921 + } 922 + } 923 + 924 + } 925 + // t.CreatedAt (string) (string) 926 + case "createdAt": 927 + 928 + { 929 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 930 + if err != nil { 931 + return err 932 + } 933 + 934 + t.CreatedAt = string(sval) 935 + } 936 + // t.PullRoundIdx (int64) (int64) 937 + case "pullRoundIdx": 938 + { 939 + 940 + b, err := cr.ReadByte() 941 + if err != nil { 942 + return err 943 + } 944 + if b != cbg.CborNull[0] { 945 + if err := cr.UnreadByte(); err != nil { 946 + return err 947 + } 948 + maj, extra, err := cr.ReadHeader() 949 + if err != nil { 950 + return err 951 + } 952 + var extraI int64 953 + switch maj { 954 + case cbg.MajUnsignedInt: 955 + extraI = int64(extra) 956 + if extraI < 0 { 957 + return fmt.Errorf("int64 positive overflow") 958 + } 959 + case cbg.MajNegativeInt: 960 + extraI = int64(extra) 961 + if extraI < 0 { 962 + return fmt.Errorf("int64 negative overflow") 963 + } 964 + extraI = -1 - extraI 965 + default: 966 + return fmt.Errorf("wrong type for int64 field: %d", maj) 967 + } 968 + 969 + t.PullRoundIdx = (*int64)(&extraI) 651 970 } 652 971 } 653 972 ··· 3832 4151 } 3833 4152 3834 4153 t.Value = string(sval) 4154 + } 4155 + 4156 + default: 4157 + // Field doesn't exist on this type, so ignore it 4158 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 4159 + return err 4160 + } 4161 + } 4162 + } 4163 + 4164 + return nil 4165 + } 4166 + func (t *MarkupMarkdown) MarshalCBOR(w io.Writer) error { 4167 + if t == nil { 4168 + _, err := w.Write(cbg.CborNull) 4169 + return err 4170 + } 4171 + 4172 + cw := cbg.NewCborWriter(w) 4173 + fieldCount := 4 4174 + 4175 + if t.Blobs == nil { 4176 + fieldCount-- 4177 + } 4178 + 4179 + if t.Original == nil { 4180 + fieldCount-- 4181 + } 4182 + 4183 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 4184 + return err 4185 + } 4186 + 4187 + // t.Text (string) (string) 4188 + if len("text") > 1000000 { 4189 + return xerrors.Errorf("Value in field \"text\" was too long") 4190 + } 4191 + 4192 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("text"))); err != nil { 4193 + return err 4194 + } 4195 + if _, err := cw.WriteString(string("text")); err != nil { 4196 + return err 4197 + } 4198 + 4199 + if len(t.Text) > 1000000 { 4200 + return xerrors.Errorf("Value in field t.Text was too long") 4201 + } 4202 + 4203 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Text))); err != nil { 4204 + return err 4205 + } 4206 + if _, err := cw.WriteString(string(t.Text)); err != nil { 4207 + return err 4208 + } 4209 + 4210 + // t.LexiconTypeID (string) (string) 4211 + if len("$type") > 1000000 { 4212 + return xerrors.Errorf("Value in field \"$type\" was too long") 4213 + } 4214 + 4215 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 4216 + return err 4217 + } 4218 + if _, err := cw.WriteString(string("$type")); err != nil { 4219 + return err 4220 + } 4221 + 4222 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.markup.markdown"))); err != nil { 4223 + return err 4224 + } 4225 + if _, err := cw.WriteString(string("sh.tangled.markup.markdown")); err != nil { 4226 + return err 4227 + } 4228 + 4229 + // t.Blobs ([]*util.LexBlob) (slice) 4230 + if t.Blobs != nil { 4231 + 4232 + if len("blobs") > 1000000 { 4233 + return xerrors.Errorf("Value in field \"blobs\" was too long") 4234 + } 4235 + 4236 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("blobs"))); err != nil { 4237 + return err 4238 + } 4239 + if _, err := cw.WriteString(string("blobs")); err != nil { 4240 + return err 4241 + } 4242 + 4243 + if len(t.Blobs) > 8192 { 4244 + return xerrors.Errorf("Slice value in field t.Blobs was too long") 4245 + } 4246 + 4247 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Blobs))); err != nil { 4248 + return err 4249 + } 4250 + for _, v := range t.Blobs { 4251 + if err := v.MarshalCBOR(cw); err != nil { 4252 + return err 4253 + } 4254 + 4255 + } 4256 + } 4257 + 4258 + // t.Original (string) (string) 4259 + if t.Original != nil { 4260 + 4261 + if len("original") > 1000000 { 4262 + return xerrors.Errorf("Value in field \"original\" was too long") 4263 + } 4264 + 4265 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("original"))); err != nil { 4266 + return err 4267 + } 4268 + if _, err := cw.WriteString(string("original")); err != nil { 4269 + return err 4270 + } 4271 + 4272 + if t.Original == nil { 4273 + if _, err := cw.Write(cbg.CborNull); err != nil { 4274 + return err 4275 + } 4276 + } else { 4277 + if len(*t.Original) > 1000000 { 4278 + return xerrors.Errorf("Value in field t.Original was too long") 4279 + } 4280 + 4281 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Original))); err != nil { 4282 + return err 4283 + } 4284 + if _, err := cw.WriteString(string(*t.Original)); err != nil { 4285 + return err 4286 + } 4287 + } 4288 + } 4289 + return nil 4290 + } 4291 + 4292 + func (t *MarkupMarkdown) UnmarshalCBOR(r io.Reader) (err error) { 4293 + *t = MarkupMarkdown{} 4294 + 4295 + cr := cbg.NewCborReader(r) 4296 + 4297 + maj, extra, err := cr.ReadHeader() 4298 + if err != nil { 4299 + return err 4300 + } 4301 + defer func() { 4302 + if err == io.EOF { 4303 + err = io.ErrUnexpectedEOF 4304 + } 4305 + }() 4306 + 4307 + if maj != cbg.MajMap { 4308 + return fmt.Errorf("cbor input should be of type map") 4309 + } 4310 + 4311 + if extra > cbg.MaxLength { 4312 + return fmt.Errorf("MarkupMarkdown: map struct too large (%d)", extra) 4313 + } 4314 + 4315 + n := extra 4316 + 4317 + nameBuf := make([]byte, 8) 4318 + for i := uint64(0); i < n; i++ { 4319 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4320 + if err != nil { 4321 + return err 4322 + } 4323 + 4324 + if !ok { 4325 + // Field doesn't exist on this type, so ignore it 4326 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 4327 + return err 4328 + } 4329 + continue 4330 + } 4331 + 4332 + switch string(nameBuf[:nameLen]) { 4333 + // t.Text (string) (string) 4334 + case "text": 4335 + 4336 + { 4337 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4338 + if err != nil { 4339 + return err 4340 + } 4341 + 4342 + t.Text = string(sval) 4343 + } 4344 + // t.LexiconTypeID (string) (string) 4345 + case "$type": 4346 + 4347 + { 4348 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4349 + if err != nil { 4350 + return err 4351 + } 4352 + 4353 + t.LexiconTypeID = string(sval) 4354 + } 4355 + // t.Blobs ([]*util.LexBlob) (slice) 4356 + case "blobs": 4357 + 4358 + maj, extra, err = cr.ReadHeader() 4359 + if err != nil { 4360 + return err 4361 + } 4362 + 4363 + if extra > 8192 { 4364 + return fmt.Errorf("t.Blobs: array too large (%d)", extra) 4365 + } 4366 + 4367 + if maj != cbg.MajArray { 4368 + return fmt.Errorf("expected cbor array") 4369 + } 4370 + 4371 + if extra > 0 { 4372 + t.Blobs = make([]*util.LexBlob, extra) 4373 + } 4374 + 4375 + for i := 0; i < int(extra); i++ { 4376 + { 4377 + var maj byte 4378 + var extra uint64 4379 + var err error 4380 + _ = maj 4381 + _ = extra 4382 + _ = err 4383 + 4384 + { 4385 + 4386 + b, err := cr.ReadByte() 4387 + if err != nil { 4388 + return err 4389 + } 4390 + if b != cbg.CborNull[0] { 4391 + if err := cr.UnreadByte(); err != nil { 4392 + return err 4393 + } 4394 + t.Blobs[i] = new(util.LexBlob) 4395 + if err := t.Blobs[i].UnmarshalCBOR(cr); err != nil { 4396 + return xerrors.Errorf("unmarshaling t.Blobs[i] pointer: %w", err) 4397 + } 4398 + } 4399 + 4400 + } 4401 + 4402 + } 4403 + } 4404 + // t.Original (string) (string) 4405 + case "original": 4406 + 4407 + { 4408 + b, err := cr.ReadByte() 4409 + if err != nil { 4410 + return err 4411 + } 4412 + if b != cbg.CborNull[0] { 4413 + if err := cr.UnreadByte(); err != nil { 4414 + return err 4415 + } 4416 + 4417 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4418 + if err != nil { 4419 + return err 4420 + } 4421 + 4422 + t.Original = (*string)(&sval) 4423 + } 3835 4424 } 3836 4425 3837 4426 default:
+88
api/tangled/feedcomment.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.feed.comment 6 + 7 + import ( 8 + "bytes" 9 + "encoding/json" 10 + "fmt" 11 + "io" 12 + 13 + comatprototypes "github.com/bluesky-social/indigo/api/atproto" 14 + "github.com/bluesky-social/indigo/lex/util" 15 + cbg "github.com/whyrusleeping/cbor-gen" 16 + ) 17 + 18 + const ( 19 + FeedCommentNSID = "sh.tangled.feed.comment" 20 + ) 21 + 22 + func init() { 23 + util.RegisterType("sh.tangled.feed.comment", &FeedComment{}) 24 + } // 25 + // RECORDTYPE: FeedComment 26 + type FeedComment struct { 27 + LexiconTypeID string `json:"$type,const=sh.tangled.feed.comment" cborgen:"$type,const=sh.tangled.feed.comment"` 28 + Body *FeedComment_Body `json:"body" cborgen:"body"` 29 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 30 + // pullRoundIdx: optional pull submission round index. required when subject is sh.tangled.repo.pull 31 + PullRoundIdx *int64 `json:"pullRoundIdx,omitempty" cborgen:"pullRoundIdx,omitempty"` 32 + ReplyTo *comatprototypes.RepoStrongRef `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 33 + Subject *comatprototypes.RepoStrongRef `json:"subject" cborgen:"subject"` 34 + } 35 + 36 + type FeedComment_Body struct { 37 + MarkupMarkdown *MarkupMarkdown 38 + } 39 + 40 + func (t *FeedComment_Body) MarshalJSON() ([]byte, error) { 41 + if t.MarkupMarkdown != nil { 42 + t.MarkupMarkdown.LexiconTypeID = "sh.tangled.markup.markdown" 43 + return json.Marshal(t.MarkupMarkdown) 44 + } 45 + return nil, fmt.Errorf("cannot marshal empty enum") 46 + } 47 + func (t *FeedComment_Body) UnmarshalJSON(b []byte) error { 48 + typ, err := util.TypeExtract(b) 49 + if err != nil { 50 + return err 51 + } 52 + 53 + switch typ { 54 + case "sh.tangled.markup.markdown": 55 + t.MarkupMarkdown = new(MarkupMarkdown) 56 + return json.Unmarshal(b, t.MarkupMarkdown) 57 + 58 + default: 59 + return nil 60 + } 61 + } 62 + 63 + func (t *FeedComment_Body) MarshalCBOR(w io.Writer) error { 64 + 65 + if t == nil { 66 + _, err := w.Write(cbg.CborNull) 67 + return err 68 + } 69 + if t.MarkupMarkdown != nil { 70 + return t.MarkupMarkdown.MarshalCBOR(w) 71 + } 72 + return fmt.Errorf("cannot cbor marshal empty enum") 73 + } 74 + func (t *FeedComment_Body) UnmarshalCBOR(r io.Reader) error { 75 + typ, b, err := util.CborTypeExtractReader(r) 76 + if err != nil { 77 + return err 78 + } 79 + 80 + switch typ { 81 + case "sh.tangled.markup.markdown": 82 + t.MarkupMarkdown = new(MarkupMarkdown) 83 + return t.MarkupMarkdown.UnmarshalCBOR(bytes.NewReader(b)) 84 + 85 + default: 86 + return nil 87 + } 88 + }
+29
api/tangled/markupmarkdown.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.markup.markdown 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + MarkupMarkdownNSID = "sh.tangled.markup.markdown" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.markup.markdown#main", &MarkupMarkdown{}) 17 + } // MarkupMarkdown is a "main" in the sh.tangled.markup.markdown schema. 18 + // Tangled Flavored Markdown format text 19 + // 20 + // RECORDTYPE: MarkupMarkdown 21 + type MarkupMarkdown struct { 22 + LexiconTypeID string `json:"$type,const=sh.tangled.markup.markdown" cborgen:"$type,const=sh.tangled.markup.markdown"` 23 + // blobs: list of blobs referenced in markdown 24 + Blobs []*util.LexBlob `json:"blobs,omitempty" cborgen:"blobs,omitempty"` 25 + // original: Original Markdown before post-processing. Used to restore original input on edit. 26 + Original *string `json:"original,omitempty" cborgen:"original,omitempty"` 27 + // text: Final post-processed markdown content that will be rendered 28 + Text string `json:"text" cborgen:"text"` 29 + }
+283
appview/db/comments.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "fmt" 7 + "log" 8 + "sort" 9 + "strings" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/api/atproto" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/orm" 17 + ) 18 + 19 + func PutComment(tx *sql.Tx, c *models.Comment, references []syntax.ATURI) error { 20 + if c.Collection == "" { 21 + c.Collection = tangled.FeedCommentNSID 22 + } 23 + 24 + var bodyBlobs, replyToUri, replyToCid *string 25 + if len(c.Body.Blobs) > 0 { 26 + encoded, err := json.Marshal(c.Body.Blobs) 27 + if err != nil { 28 + return fmt.Errorf("encoding blobs to json: %w", err) 29 + } 30 + encodedStr := string(encoded) 31 + bodyBlobs = &encodedStr 32 + } 33 + if c.ReplyTo != nil { 34 + replyToUri = &c.ReplyTo.Uri 35 + replyToCid = &c.ReplyTo.Cid 36 + } 37 + result, err := tx.Exec( 38 + // users can change the 'created' date. 39 + // skip update entirely if cid is unchanged. 40 + `insert into comments ( 41 + did, 42 + collection, 43 + rkey, 44 + cid, 45 + subject_uri, 46 + subject_cid, 47 + body_text, 48 + body_original, 49 + body_blobs, 50 + created, 51 + reply_to_uri, 52 + reply_to_cid, 53 + pull_round_idx 54 + ) 55 + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 56 + on conflict(did, collection, rkey) 57 + do update set 58 + cid = excluded.cid, 59 + subject_uri = excluded.subject_uri, 60 + subject_cid = excluded.subject_cid, 61 + body_text = excluded.body_text, 62 + body_original = excluded.body_original, 63 + body_blobs = excluded.body_blobs, 64 + created = excluded.created, 65 + reply_to_uri = excluded.reply_to_uri, 66 + reply_to_cid = excluded.reply_to_cid, 67 + pull_round_idx = excluded.pull_round_idx, 68 + edited = ? 69 + where comments.cid != excluded.cid`, 70 + c.Did, 71 + c.Collection, 72 + c.Rkey, 73 + c.Cid, 74 + c.Subject.Uri, 75 + c.Subject.Cid, 76 + c.Body.Text, 77 + c.Body.Original, 78 + bodyBlobs, 79 + c.Created.Format(time.RFC3339), 80 + replyToUri, 81 + replyToCid, 82 + c.PullRoundIdx, 83 + time.Now().Format(time.RFC3339), 84 + ) 85 + if err != nil { 86 + return err 87 + } 88 + 89 + c.Id, err = result.LastInsertId() 90 + if err != nil { 91 + return err 92 + } 93 + 94 + affected, err := result.RowsAffected() 95 + if err != nil { 96 + return err 97 + } 98 + 99 + if affected < 1 { 100 + log.Println("record is already stored. skipping operation") 101 + return nil 102 + } 103 + 104 + // update references when comment is updated 105 + if err := putReferences(tx, c.AtUri(), references); err != nil { 106 + return fmt.Errorf("put reference_links: %w", err) 107 + } 108 + 109 + return nil 110 + } 111 + 112 + // PurgeComments actually purges a comment row from db instead of marking it as "deleted" 113 + func PurgeComments(e Execer, filters ...orm.Filter) error { 114 + var conditions []string 115 + var args []any 116 + for _, filter := range filters { 117 + conditions = append(conditions, filter.Condition()) 118 + args = append(args, filter.Arg()...) 119 + } 120 + 121 + whereClause := "" 122 + if conditions != nil { 123 + whereClause = " where " + strings.Join(conditions, " and ") 124 + } 125 + 126 + _, err := e.Exec(fmt.Sprintf(`delete from comments %s`, whereClause), args...) 127 + return err 128 + } 129 + 130 + func DeleteComments(e Execer, filters ...orm.Filter) error { 131 + var conditions []string 132 + var args []any 133 + for _, filter := range filters { 134 + conditions = append(conditions, filter.Condition()) 135 + args = append(args, filter.Arg()...) 136 + } 137 + 138 + whereClause := "" 139 + if conditions != nil { 140 + whereClause = " where " + strings.Join(conditions, " and ") 141 + } 142 + 143 + query := fmt.Sprintf( 144 + `update comments 145 + set body_text = "", 146 + body_original = null, 147 + body_blobs = null, 148 + deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') 149 + %s`, 150 + whereClause, 151 + ) 152 + 153 + _, err := e.Exec(query, args...) 154 + return err 155 + } 156 + 157 + func GetComment(e Execer, filters ...orm.Filter) (models.Comment, error) { 158 + comments, err := GetComments(e, filters...) 159 + if err != nil { 160 + return models.Comment{}, err 161 + } 162 + if len(comments) != 1 { 163 + return models.Comment{}, fmt.Errorf("expected 1 comment, got %d", len(comments)) 164 + } 165 + return comments[0], nil 166 + } 167 + 168 + func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { 169 + var comments []models.Comment 170 + 171 + var conditions []string 172 + var args []any 173 + for _, filter := range filters { 174 + conditions = append(conditions, filter.Condition()) 175 + args = append(args, filter.Arg()...) 176 + } 177 + 178 + whereClause := "" 179 + if conditions != nil { 180 + whereClause = " where " + strings.Join(conditions, " and ") 181 + } 182 + 183 + query := fmt.Sprintf(` 184 + select 185 + id, 186 + did, 187 + collection, 188 + rkey, 189 + cid, 190 + subject_uri, 191 + subject_cid, 192 + body_text, 193 + body_original, 194 + body_blobs, 195 + created, 196 + reply_to_uri, 197 + reply_to_cid, 198 + pull_round_idx, 199 + edited, 200 + deleted 201 + from 202 + comments 203 + %s 204 + `, whereClause) 205 + 206 + rows, err := e.Query(query, args...) 207 + if err != nil { 208 + return nil, err 209 + } 210 + defer rows.Close() 211 + 212 + for rows.Next() { 213 + var comment models.Comment 214 + var created string 215 + var cid, bodyBlobs, replyToUri, replyToCid, edited, deleted sql.Null[string] 216 + err := rows.Scan( 217 + &comment.Id, 218 + &comment.Did, 219 + &comment.Collection, 220 + &comment.Rkey, 221 + &cid, 222 + &comment.Subject.Uri, 223 + &comment.Subject.Cid, 224 + &comment.Body.Text, 225 + &comment.Body.Original, 226 + &bodyBlobs, 227 + &created, 228 + &replyToUri, 229 + &replyToCid, 230 + &comment.PullRoundIdx, 231 + &edited, 232 + &deleted, 233 + ) 234 + if err != nil { 235 + return nil, err 236 + } 237 + 238 + if cid.Valid && cid.V != "" { 239 + comment.Cid = syntax.CID(cid.V) 240 + } 241 + 242 + if bodyBlobs.Valid && bodyBlobs.V != "" { 243 + if err := json.Unmarshal([]byte(bodyBlobs.V), &comment.Body.Blobs); err != nil { 244 + return nil, fmt.Errorf("decoding blobs: %w", err) 245 + } 246 + } 247 + 248 + if t, err := time.Parse(time.RFC3339, created); err == nil { 249 + comment.Created = t 250 + } 251 + 252 + if replyToUri.Valid && replyToCid.Valid { 253 + comment.ReplyTo = &atproto.RepoStrongRef{ 254 + Uri: replyToUri.V, 255 + Cid: replyToCid.V, 256 + } 257 + } 258 + 259 + if edited.Valid { 260 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 261 + comment.Edited = &t 262 + } 263 + } 264 + 265 + if deleted.Valid { 266 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 267 + comment.Deleted = &t 268 + } 269 + } 270 + 271 + comments = append(comments, comment) 272 + } 273 + 274 + if err := rows.Err(); err != nil { 275 + return nil, err 276 + } 277 + 278 + sort.Slice(comments, func(i, j int) bool { 279 + return comments[i].Created.Before(comments[j].Created) 280 + }) 281 + 282 + return comments, nil 283 + }
+149
appview/db/db.go
··· 1409 1409 return err 1410 1410 }) 1411 1411 1412 + orm.RunMigration(conn, logger, "add-comments-table", func(tx *sql.Tx) error { 1413 + _, err := tx.Exec(` 1414 + drop table if exists comments; 1415 + 1416 + create table comments ( 1417 + -- identifiers 1418 + id integer primary key autoincrement, 1419 + 1420 + did text not null, 1421 + collection text not null default 'sh.tangled.feed.comment', 1422 + rkey text not null, 1423 + at_uri text generated always as ('at://' || did || '/' || collection || '/' || rkey) stored, 1424 + cid text, 1425 + 1426 + -- content 1427 + subject_uri text not null, -- at_uri of subject (issue, pr, string) 1428 + subject_cid text not null, -- cid of subject 1429 + 1430 + body_text text not null, 1431 + body_original text, 1432 + body_blobs text, -- json 1433 + 1434 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1435 + 1436 + reply_to_uri text, -- at_uri of parent comment 1437 + reply_to_cid text, -- cid of parent comment 1438 + 1439 + pull_round_idx integer, -- pull round index. required when subject is sh.tangled.repo.pull 1440 + 1441 + -- appview-local information 1442 + edited text, 1443 + deleted text, 1444 + 1445 + unique(did, collection, rkey) 1446 + ); 1447 + 1448 + insert into comments ( 1449 + did, 1450 + collection, 1451 + rkey, 1452 + subject_uri, 1453 + subject_cid, -- we need to know cid 1454 + body_text, 1455 + created, 1456 + reply_to_uri, 1457 + reply_to_cid, -- we need to know cid 1458 + edited, 1459 + deleted 1460 + ) 1461 + select 1462 + did, 1463 + 'sh.tangled.repo.issue.comment', 1464 + rkey, 1465 + issue_at, 1466 + '', 1467 + body, 1468 + created, 1469 + reply_to, 1470 + '', 1471 + edited, 1472 + deleted 1473 + from issue_comments 1474 + where rkey is not null; 1475 + 1476 + insert into comments ( 1477 + did, 1478 + collection, 1479 + rkey, 1480 + subject_uri, 1481 + subject_cid, -- we need to know cid 1482 + body_text, 1483 + created, 1484 + pull_round_idx 1485 + ) 1486 + select 1487 + c.owner_did, 1488 + 'sh.tangled.repo.pull.comment', 1489 + substr( 1490 + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey 1491 + instr( 1492 + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey 1493 + '/' 1494 + ) + 1 1495 + ), -- rkey 1496 + p.at_uri, 1497 + '', 1498 + c.body, 1499 + c.created, 1500 + s.round_number 1501 + from pull_comments c 1502 + join pulls p on c.repo_at = p.repo_at and c.pull_id = p.pull_id 1503 + join pull_submissions s on s.id = c.submission_id; 1504 + `) 1505 + return err 1506 + }) 1507 + 1508 + orm.RunMigration(conn, logger, "add-pds-migration", func(tx *sql.Tx) error { 1509 + _, err := tx.Exec(` 1510 + create table if not exists pds_migration ( 1511 + name text not null, 1512 + 1513 + -- record at_uri 1514 + did text not null, 1515 + collection text not null, 1516 + rkey text not null, 1517 + 1518 + status text not null default 'pending', 1519 + error_msg text, 1520 + retry_count integer not null default 0, 1521 + retry_after integer not null default 0, 1522 + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1523 + 1524 + unique(name, did, collection, rkey) 1525 + ); 1526 + 1527 + insert into pds_migration (name, did, collection, rkey) 1528 + select 1529 + 'use-feed-comment', 1530 + did, 1531 + collection, 1532 + rkey 1533 + from comments 1534 + where collection <> 'sh.tangled.feed.comment'; 1535 + `) 1536 + return err 1537 + }) 1538 + 1539 + // orm.RunMigration(conn, logger, "unify-pds-record-migration-table", func(tx *sql.Tx) error { 1540 + // _, err := tx.Exec(` 1541 + // insert into pds_migration ( 1542 + // name, 1543 + // did, 1544 + // collection, 1545 + // rkey, 1546 + // status, 1547 + // updated_at 1548 + // ) 1549 + // select 1550 + // 'add-repo-did', 1551 + // user_did, 1552 + // record_nsid, 1553 + // record_rkey, 1554 + // status, 1555 + // updated_at 1556 + // from pds_rewrite_status 1557 + // `) 1558 + // return err 1559 + // }) 1560 + 1412 1561 return &DB{ 1413 1562 db, 1414 1563 logger,
+6 -186
appview/db/issues.go
··· 100 100 } 101 101 102 102 func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { 103 - issueMap := make(map[string]*models.Issue) // at-uri -> issue 103 + issueMap := make(map[syntax.ATURI]*models.Issue) // at-uri -> issue 104 104 105 105 var conditions []string 106 106 var args []any ··· 196 196 } 197 197 } 198 198 199 - atUri := issue.AtUri().String() 200 - issueMap[atUri] = &issue 199 + issueMap[issue.AtUri()] = &issue 201 200 } 202 201 203 202 // collect reverse repos ··· 229 228 // collect comments 230 229 issueAts := slices.Collect(maps.Keys(issueMap)) 231 230 232 - comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) 231 + comments, err := GetComments(e, orm.FilterIn("subject_uri", issueAts)) 233 232 if err != nil { 234 233 return nil, fmt.Errorf("failed to query comments: %w", err) 235 234 } 236 235 for i := range comments { 237 - issueAt := comments[i].IssueAt 236 + issueAt := syntax.ATURI(comments[i].Subject.Uri) 238 237 if issue, ok := issueMap[issueAt]; ok { 239 238 issue.Comments = append(issue.Comments, comments[i]) 240 239 } ··· 246 245 return nil, fmt.Errorf("failed to query labels: %w", err) 247 246 } 248 247 for issueAt, labels := range allLabels { 249 - if issue, ok := issueMap[issueAt.String()]; ok { 248 + if issue, ok := issueMap[issueAt]; ok { 250 249 issue.Labels = labels 251 250 } 252 251 } ··· 257 256 return nil, fmt.Errorf("failed to query reference_links: %w", err) 258 257 } 259 258 for issueAt, references := range allReferences { 260 - if issue, ok := issueMap[issueAt.String()]; ok { 259 + if issue, ok := issueMap[issueAt]; ok { 261 260 issue.References = references 262 261 } 263 262 } ··· 293 292 294 293 func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) { 295 294 return GetIssuesPaginated(e, pagination.Page{}, filters...) 296 - } 297 - 298 - func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 299 - result, err := tx.Exec( 300 - `insert into issue_comments ( 301 - did, 302 - rkey, 303 - issue_at, 304 - body, 305 - reply_to, 306 - created, 307 - edited 308 - ) 309 - values (?, ?, ?, ?, ?, ?, null) 310 - on conflict(did, rkey) do update set 311 - issue_at = excluded.issue_at, 312 - body = excluded.body, 313 - edited = case 314 - when 315 - issue_comments.issue_at != excluded.issue_at 316 - or issue_comments.body != excluded.body 317 - or issue_comments.reply_to != excluded.reply_to 318 - then ? 319 - else issue_comments.edited 320 - end`, 321 - c.Did, 322 - c.Rkey, 323 - c.IssueAt, 324 - c.Body, 325 - c.ReplyTo, 326 - c.Created.Format(time.RFC3339), 327 - time.Now().Format(time.RFC3339), 328 - ) 329 - if err != nil { 330 - return 0, err 331 - } 332 - 333 - id, err := result.LastInsertId() 334 - if err != nil { 335 - return 0, err 336 - } 337 - 338 - if err := putReferences(tx, c.AtUri(), c.References); err != nil { 339 - return 0, fmt.Errorf("put reference_links: %w", err) 340 - } 341 - 342 - return id, nil 343 - } 344 - 345 - func DeleteIssueComments(e Execer, filters ...orm.Filter) error { 346 - var conditions []string 347 - var args []any 348 - for _, filter := range filters { 349 - conditions = append(conditions, filter.Condition()) 350 - args = append(args, filter.Arg()...) 351 - } 352 - 353 - whereClause := "" 354 - if conditions != nil { 355 - whereClause = " where " + strings.Join(conditions, " and ") 356 - } 357 - 358 - query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 359 - 360 - _, err := e.Exec(query, args...) 361 - return err 362 - } 363 - 364 - func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { 365 - commentMap := make(map[string]*models.IssueComment) 366 - 367 - var conditions []string 368 - var args []any 369 - for _, filter := range filters { 370 - conditions = append(conditions, filter.Condition()) 371 - args = append(args, filter.Arg()...) 372 - } 373 - 374 - whereClause := "" 375 - if conditions != nil { 376 - whereClause = " where " + strings.Join(conditions, " and ") 377 - } 378 - 379 - query := fmt.Sprintf(` 380 - select 381 - id, 382 - did, 383 - rkey, 384 - issue_at, 385 - reply_to, 386 - body, 387 - created, 388 - edited, 389 - deleted 390 - from 391 - issue_comments 392 - %s 393 - `, whereClause) 394 - 395 - rows, err := e.Query(query, args...) 396 - if err != nil { 397 - return nil, err 398 - } 399 - defer rows.Close() 400 - 401 - for rows.Next() { 402 - var comment models.IssueComment 403 - var created string 404 - var rkey, edited, deleted, replyTo sql.Null[string] 405 - err := rows.Scan( 406 - &comment.Id, 407 - &comment.Did, 408 - &rkey, 409 - &comment.IssueAt, 410 - &replyTo, 411 - &comment.Body, 412 - &created, 413 - &edited, 414 - &deleted, 415 - ) 416 - if err != nil { 417 - return nil, err 418 - } 419 - 420 - // this is a remnant from old times, newer comments always have rkey 421 - if rkey.Valid { 422 - comment.Rkey = rkey.V 423 - } 424 - 425 - if t, err := time.Parse(time.RFC3339, created); err == nil { 426 - comment.Created = t 427 - } 428 - 429 - if edited.Valid { 430 - if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 431 - comment.Edited = &t 432 - } 433 - } 434 - 435 - if deleted.Valid { 436 - if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 437 - comment.Deleted = &t 438 - } 439 - } 440 - 441 - if replyTo.Valid { 442 - comment.ReplyTo = &replyTo.V 443 - } 444 - 445 - atUri := comment.AtUri().String() 446 - commentMap[atUri] = &comment 447 - } 448 - 449 - if err = rows.Err(); err != nil { 450 - return nil, err 451 - } 452 - 453 - // collect references for each comments 454 - commentAts := slices.Collect(maps.Keys(commentMap)) 455 - allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 456 - if err != nil { 457 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 458 - } 459 - for commentAt, references := range allReferences { 460 - if comment, ok := commentMap[commentAt.String()]; ok { 461 - comment.References = references 462 - } 463 - } 464 - 465 - var comments []models.IssueComment 466 - for _, c := range commentMap { 467 - comments = append(comments, *c) 468 - } 469 - 470 - sort.Slice(comments, func(i, j int) bool { 471 - return comments[i].Created.After(comments[j].Created) 472 - }) 473 - 474 - return comments, nil 475 295 } 476 296 477 297 func DeleteIssues(tx *sql.Tx, did, rkey string) error {
+89
appview/db/migration.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/appview/models" 9 + ) 10 + 11 + // "migration" for records stored in user's PDS, not AppView DB 12 + 13 + // ListPendingPdsRecordMigrations queries list of pending PDS migrations for given user. 14 + // Only pending migrations whose `retry_after` has elapsed are returned. 15 + func ListPendingPdsRecordMigrations(ctx context.Context, e Execer, user syntax.DID) ([]*models.PDSMigration, error) { 16 + rows, err := e.QueryContext(ctx, 17 + `with picked as ( 18 + select rowid 19 + from pds_migration 20 + where did = ? 21 + and status = 'pending' 22 + and retry_after < ? 23 + ) 24 + update pds_migration 25 + set status = ? 26 + where rowid in (select rowid from picked) 27 + returning name, did, collection, rkey, status, error_msg, retry_count, retry_after`, 28 + user, 29 + time.Now().Unix(), 30 + models.PDSMigrationStatusRunning, 31 + ) 32 + if err != nil { 33 + return nil, err 34 + } 35 + defer rows.Close() 36 + 37 + var migrations []*models.PDSMigration 38 + for rows.Next() { 39 + var migration models.PDSMigration 40 + if err := rows.Scan( 41 + &migration.Name, 42 + &migration.Did, 43 + &migration.Collection, 44 + &migration.Rkey, 45 + &migration.Status, 46 + &migration.ErrorMsg, 47 + &migration.RetryCount, 48 + &migration.RetryAfter, 49 + ); err != nil { 50 + return nil, err 51 + } 52 + migrations = append(migrations, &migration) 53 + } 54 + if err := rows.Err(); err != nil { 55 + return nil, err 56 + } 57 + 58 + return migrations, nil 59 + } 60 + 61 + func EnqueuePdsRecordMigration(ctx context.Context, e Execer, name string, did syntax.DID, collection syntax.NSID, rkey syntax.RecordKey) error { 62 + _, err := e.ExecContext(ctx, 63 + `insert into pds_migration (name, did, collection, rkey) 64 + values (?, ?, ?, ?)`, 65 + name, did, collection, rkey, 66 + ) 67 + return err 68 + } 69 + 70 + func UpdatePdsRecordMigration(ctx context.Context, e Execer, migration *models.PDSMigration) error { 71 + _, err := e.ExecContext(ctx, 72 + `update pds_migration 73 + set status = ?, 74 + error_msg = ?, 75 + retry_count = ?, 76 + retry_after = ?, 77 + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 78 + where name = ? and did = ? and collection = ? and rkey = ?`, 79 + migration.Status, 80 + migration.ErrorMsg, 81 + migration.RetryCount, 82 + migration.RetryAfter, 83 + migration.Name, 84 + migration.Did, 85 + migration.Collection, 86 + migration.Rkey, 87 + ) 88 + return err 89 + }
+15 -132
appview/db/pulls.go
··· 524 524 } 525 525 defer rows.Close() 526 526 527 - submissionMap := make(map[int]*models.PullSubmission) 527 + pullMap := make(map[syntax.ATURI][]*models.PullSubmission) 528 528 529 529 for rows.Next() { 530 530 var submission models.PullSubmission ··· 572 572 submission.Blob.Size = patchBlobSize.V 573 573 } 574 574 575 - submissionMap[submission.ID] = &submission 575 + pullMap[submission.PullAt] = append(pullMap[submission.PullAt], &submission) 576 576 } 577 577 578 578 if err := rows.Err(); err != nil { 579 579 return nil, err 580 580 } 581 581 582 - // Get comments for all submissions using GetPullComments 583 - submissionIds := slices.Collect(maps.Keys(submissionMap)) 584 - comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) 582 + // Get comments for all submissions using GetComments 583 + pullAts := slices.Collect(maps.Keys(pullMap)) 584 + comments, err := GetComments(e, orm.FilterIn("subject_uri", pullAts)) 585 585 if err != nil { 586 586 return nil, fmt.Errorf("failed to get pull comments: %w", err) 587 587 } 588 588 for _, comment := range comments { 589 - if submission, ok := submissionMap[comment.SubmissionId]; ok { 590 - submission.Comments = append(submission.Comments, comment) 589 + if comment.PullRoundIdx != nil { 590 + roundIdx := *comment.PullRoundIdx 591 + if submissions, ok := pullMap[syntax.ATURI(comment.Subject.Uri)]; ok { 592 + if roundIdx < len(submissions) { 593 + submission := submissions[roundIdx] 594 + submission.Comments = append(submission.Comments, comment) 595 + } 596 + } 591 597 } 592 598 } 593 599 594 - // group the submissions by pull_at 595 - m := make(map[syntax.ATURI][]*models.PullSubmission) 596 - for _, s := range submissionMap { 597 - m[s.PullAt] = append(m[s.PullAt], s) 598 - } 599 - 600 600 // sort each one by round number 601 - for _, s := range m { 601 + for _, s := range pullMap { 602 602 slices.SortFunc(s, func(a, b *models.PullSubmission) int { 603 603 return cmp.Compare(a.RoundNumber, b.RoundNumber) 604 604 }) 605 605 } 606 606 607 - return m, nil 608 - } 609 - 610 - func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { 611 - var conditions []string 612 - var args []any 613 - for _, filter := range filters { 614 - conditions = append(conditions, filter.Condition()) 615 - args = append(args, filter.Arg()...) 616 - } 617 - 618 - whereClause := "" 619 - if conditions != nil { 620 - whereClause = " where " + strings.Join(conditions, " and ") 621 - } 622 - 623 - query := fmt.Sprintf(` 624 - select 625 - id, 626 - pull_id, 627 - submission_id, 628 - repo_at, 629 - owner_did, 630 - comment_at, 631 - body, 632 - created 633 - from 634 - pull_comments 635 - %s 636 - order by 637 - created asc 638 - `, whereClause) 639 - 640 - rows, err := e.Query(query, args...) 641 - if err != nil { 642 - return nil, err 643 - } 644 - defer rows.Close() 645 - 646 - commentMap := make(map[string]*models.PullComment) 647 - for rows.Next() { 648 - var comment models.PullComment 649 - var createdAt string 650 - err := rows.Scan( 651 - &comment.ID, 652 - &comment.PullId, 653 - &comment.SubmissionId, 654 - &comment.RepoAt, 655 - &comment.OwnerDid, 656 - &comment.CommentAt, 657 - &comment.Body, 658 - &createdAt, 659 - ) 660 - if err != nil { 661 - return nil, err 662 - } 663 - 664 - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 665 - comment.Created = t 666 - } 667 - 668 - atUri := comment.AtUri().String() 669 - commentMap[atUri] = &comment 670 - } 671 - 672 - if err := rows.Err(); err != nil { 673 - return nil, err 674 - } 675 - 676 - // collect references for each comments 677 - commentAts := slices.Collect(maps.Keys(commentMap)) 678 - allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 679 - if err != nil { 680 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 681 - } 682 - for commentAt, references := range allReferences { 683 - if comment, ok := commentMap[commentAt.String()]; ok { 684 - comment.References = references 685 - } 686 - } 687 - 688 - var comments []models.PullComment 689 - for _, c := range commentMap { 690 - comments = append(comments, *c) 691 - } 692 - 693 - sort.Slice(comments, func(i, j int) bool { 694 - return comments[i].Created.Before(comments[j].Created) 695 - }) 696 - 697 - return comments, nil 607 + return pullMap, nil 698 608 } 699 609 700 610 // timeframe here is directly passed into the sql query filter, and any ··· 771 681 } 772 682 773 683 return pulls, nil 774 - } 775 - 776 - func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 777 - query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 778 - res, err := tx.Exec( 779 - query, 780 - comment.OwnerDid, 781 - comment.RepoAt, 782 - comment.SubmissionId, 783 - comment.CommentAt, 784 - comment.PullId, 785 - comment.Body, 786 - ) 787 - if err != nil { 788 - return 0, err 789 - } 790 - 791 - i, err := res.LastInsertId() 792 - if err != nil { 793 - return 0, err 794 - } 795 - 796 - if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 797 - return 0, fmt.Errorf("put reference_links: %w", err) 798 - } 799 - 800 - return i, nil 801 684 } 802 685 803 686 // use with transaction
+99 -35
appview/db/reaction.go
··· 1 1 package db 2 2 3 3 import ( 4 + "fmt" 4 5 "log" 5 6 "time" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 8 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 9 11 ) 10 12 11 13 func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error { ··· 71 73 return count, nil 72 74 } 73 75 76 + // GetReactionDisplayDataMap returns map of [models.ReactionKind]->[models.ReactionDisplayData] 74 77 func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 75 - query := ` 76 - select kind, reacted_by_did, 77 - row_number() over (partition by kind order by created asc) as rn, 78 - count(*) over (partition by kind) as total 79 - from reactions 80 - where thread_at = ? 81 - order by kind, created asc` 78 + reactionMaps, err := ListReactionDisplayDataMap(e, []syntax.ATURI{threadAt}, userLimit) 79 + return reactionMaps[threadAt], err 80 + } 81 + 82 + // ListReactionDisplayDataMap returns map of [syntax.ATURI]->[models.ReactionKind]->[models.ReactionDisplayData] 83 + func ListReactionDisplayDataMap(e Execer, threads []syntax.ATURI, userLimit int) (map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData, error) { 84 + if len(threads) == 0 { 85 + return nil, nil 86 + } 82 87 83 - rows, err := e.Query(query, threadAt) 88 + filter := orm.FilterIn("thread_at", threads) 89 + args := filter.Arg() 90 + args = append(args, userLimit) 91 + rows, err := e.Query( 92 + fmt.Sprintf( 93 + `with ranked_reactions as ( 94 + select 95 + thread_at, 96 + kind, 97 + reacted_by_did, 98 + row_number() over (partition by thread_at, kind order by created asc) as rn, 99 + count(*) over (partition by thread_at, kind) as total 100 + from reactions 101 + where %s 102 + ) 103 + select thread_at, kind, reacted_by_did, total 104 + from ranked_reactions 105 + where rn <= ? 106 + order by thread_at, kind, rn asc`, 107 + filter.Condition(), 108 + ), 109 + args..., 110 + ) 84 111 if err != nil { 85 - return nil, err 112 + return nil, fmt.Errorf("querying: %w", err) 86 113 } 87 114 defer rows.Close() 88 115 89 - reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 90 - for _, kind := range models.OrderedReactionKinds { 91 - reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 92 - } 116 + // aturi -> kind -> {count,users} 117 + result := make(map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData) 93 118 94 119 for rows.Next() { 120 + var aturi syntax.ATURI 95 121 var kind models.ReactionKind 96 - var did string 97 - var rn, total int 98 - if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 99 - return nil, err 122 + var did syntax.DID 123 + var count int 124 + 125 + if err := rows.Scan(&aturi, &kind, &did, &count); err != nil { 126 + return nil, fmt.Errorf("scanning row: %w", err) 100 127 } 101 128 102 - data := reactionMap[kind] 103 - data.Count = total 104 - if userLimit > 0 && rn <= userLimit { 105 - data.Users = append(data.Users, did) 129 + if _, ok := result[aturi]; !ok { 130 + result[aturi] = make(map[models.ReactionKind]models.ReactionDisplayData) 106 131 } 107 - reactionMap[kind] = data 132 + data := result[aturi][kind] 133 + data.Count = count 134 + data.Users = append(data.Users, did.String()) 135 + result[aturi][kind] = data 136 + } 137 + 138 + if err := rows.Err(); err != nil { 139 + return nil, fmt.Errorf("iterate rows: %w", err) 108 140 } 109 141 110 - return reactionMap, rows.Err() 142 + return result, nil 143 + } 144 + 145 + // GetReactionStatusMap returns map of [models.ReactionKind]->[bool] 146 + func GetReactionStatusMap(e Execer, userDid syntax.DID, threadAt syntax.ATURI) (map[models.ReactionKind]bool, error) { 147 + reactionMaps, err := ListReactionStatusMap(e, []syntax.ATURI{threadAt}, userDid) 148 + return reactionMaps[threadAt], err 111 149 } 112 150 113 - func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool { 114 - if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 115 - return false 116 - } else { 117 - return true 151 + // ListReactionStatusMap returns map of [syntax.ATURI]->[models.ReactionKind]->[bool] 152 + func ListReactionStatusMap(e Execer, threads []syntax.ATURI, userDid syntax.DID) (map[syntax.ATURI]map[models.ReactionKind]bool, error) { 153 + if len(threads) == 0 { 154 + return nil, nil 155 + } 156 + 157 + filter := orm.FilterIn("thread_at", threads) 158 + args := []any{userDid} 159 + args = append(args, filter.Arg()...) 160 + rows, err := e.Query( 161 + fmt.Sprintf( 162 + `select thread_at, kind from reactions 163 + where reacted_by_did = ? and %s`, 164 + filter.Condition(), 165 + ), 166 + args..., 167 + ) 168 + if err != nil { 169 + return nil, err 118 170 } 119 - } 171 + defer rows.Close() 172 + 173 + // aturi -> kind -> bool 174 + result := make(map[syntax.ATURI]map[models.ReactionKind]bool) 175 + 176 + for rows.Next() { 177 + var aturi syntax.ATURI 178 + var kind models.ReactionKind 179 + 180 + if err := rows.Scan(&aturi, &kind); err != nil { 181 + return nil, fmt.Errorf("scanning row: %w", err) 182 + } 120 183 121 - func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool { 122 - statusMap := map[models.ReactionKind]bool{} 123 - for _, kind := range models.OrderedReactionKinds { 124 - count := GetReactionStatus(e, userDid, threadAt, kind) 125 - statusMap[kind] = count 184 + if _, ok := result[aturi]; !ok { 185 + result[aturi] = make(map[models.ReactionKind]bool) 186 + } 187 + 188 + result[aturi][kind] = true 126 189 } 127 - return statusMap 190 + 191 + return result, nil 128 192 }
+20 -32
appview/db/reference.go
··· 11 11 "tangled.org/core/orm" 12 12 ) 13 13 14 - // ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. 14 + // ValidateReferenceLinks resolves refLinks to Issue/PR/Comment ATURIs. 15 15 // It will ignore missing refLinks. 16 16 func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 17 17 var ( ··· 53 53 values %s 54 54 ) 55 55 select 56 - i.did, i.rkey, 57 - c.did, c.rkey 56 + i.at_uri, c.at_uri 58 57 from input inp 59 58 join repos r 60 59 on r.did = inp.owner_did ··· 62 61 join issues i 63 62 on i.repo_at = r.at_uri 64 63 and i.issue_id = inp.issue_id 65 - left join issue_comments c 64 + left join comments c 66 65 on inp.comment_id is not null 67 - and c.issue_at = i.at_uri 66 + and c.subject_uri = i.at_uri 68 67 and c.id = inp.comment_id 69 68 `, 70 69 strings.Join(vals, ","), ··· 79 78 80 79 for rows.Next() { 81 80 // Scan rows 82 - var issueOwner, issueRkey string 83 - var commentOwner, commentRkey sql.NullString 81 + var issueUri string 82 + var commentUri sql.NullString 84 83 var uri syntax.ATURI 85 - if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { 84 + if err := rows.Scan(&issueUri, &commentUri); err != nil { 86 85 return nil, err 87 86 } 88 - if commentOwner.Valid && commentRkey.Valid { 89 - uri = syntax.ATURI(fmt.Sprintf( 90 - "at://%s/%s/%s", 91 - commentOwner.String, 92 - tangled.RepoIssueCommentNSID, 93 - commentRkey.String, 94 - )) 87 + if commentUri.Valid { 88 + uri = syntax.ATURI(commentUri.String) 95 89 } else { 96 - uri = syntax.ATURI(fmt.Sprintf( 97 - "at://%s/%s/%s", 98 - issueOwner, 99 - tangled.RepoIssueNSID, 100 - issueRkey, 101 - )) 90 + uri = syntax.ATURI(issueUri) 102 91 } 103 92 uris = append(uris, uri) 104 93 } ··· 124 113 values %s 125 114 ) 126 115 select 127 - p.owner_did, p.rkey, 128 - c.comment_at 116 + p.owner_did, p.rkey, c.at_uri 129 117 from input inp 130 118 join repos r 131 119 on r.did = inp.owner_did ··· 133 121 join pulls p 134 122 on p.repo_at = r.at_uri 135 123 and p.pull_id = inp.pull_id 136 - left join pull_comments c 124 + left join comments c 137 125 on inp.comment_id is not null 138 - and c.repo_at = r.at_uri and c.pull_id = p.pull_id 126 + and c.subject_uri = ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) 139 127 and c.id = inp.comment_id 140 128 `, 141 129 strings.Join(vals, ","), ··· 283 271 return nil, fmt.Errorf("get issue backlinks: %w", err) 284 272 } 285 273 backlinks = append(backlinks, ls...) 286 - ls, err = getIssueCommentBacklinks(e, target, backlinksMap[tangled.RepoIssueCommentNSID]) 274 + ls, err = getIssueCommentBacklinks(e, target, backlinksMap[tangled.FeedCommentNSID]) 287 275 if err != nil { 288 276 return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 289 277 } ··· 293 281 return nil, fmt.Errorf("get pull backlinks: %w", err) 294 282 } 295 283 backlinks = append(backlinks, ls...) 296 - ls, err = getPullCommentBacklinks(e, target, backlinksMap[tangled.RepoPullCommentNSID]) 284 + ls, err = getPullCommentBacklinks(e, target, backlinksMap[tangled.FeedCommentNSID]) 297 285 if err != nil { 298 286 return nil, fmt.Errorf("get pull_comment backlinks: %w", err) 299 287 } ··· 353 341 rows, err := e.Query( 354 342 fmt.Sprintf( 355 343 `select r.did, r.name, i.issue_id, c.id, i.title, i.open 356 - from issue_comments c 344 + from comments c 357 345 join issues i 358 - on i.at_uri = c.issue_at 346 + on i.at_uri = c.subject_uri 359 347 join repos r 360 348 on r.at_uri = i.repo_at 361 349 where %s and %s`, ··· 430 418 if len(aturis) == 0 { 431 419 return nil, nil 432 420 } 433 - filter := orm.FilterIn("c.comment_at", aturis) 421 + filter := orm.FilterIn("c.at_uri", aturis) 434 422 exclude := orm.FilterNotEq("p.at_uri", target) 435 423 rows, err := e.Query( 436 424 fmt.Sprintf( ··· 438 426 from repos r 439 427 join pulls p 440 428 on r.at_uri = p.repo_at 441 - join pull_comments c 442 - on r.at_uri = c.repo_at and p.pull_id = c.pull_id 429 + join comments c 430 + on ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) = c.subject_uri 443 431 where %s and %s`, 444 432 filter.Condition(), 445 433 exclude.Condition(),
+85 -15
appview/ingester.go
··· 25 25 "tangled.org/core/api/tangled" 26 26 "tangled.org/core/appview/config" 27 27 "tangled.org/core/appview/db" 28 + "tangled.org/core/appview/mentions" 28 29 "tangled.org/core/appview/models" 30 + "tangled.org/core/appview/notify" 29 31 "tangled.org/core/appview/serververify" 30 32 "tangled.org/core/appview/validator" 31 33 "tangled.org/core/idresolver" ··· 34 36 ) 35 37 36 38 type Ingester struct { 37 - Db db.DbWrapper 38 - Enforcer *rbac.Enforcer 39 - IdResolver *idresolver.Resolver 40 - Config *config.Config 41 - Logger *slog.Logger 42 - Validator *validator.Validator 39 + Db db.DbWrapper 40 + Enforcer *rbac.Enforcer 41 + IdResolver *idresolver.Resolver 42 + Config *config.Config 43 + Logger *slog.Logger 44 + Validator *validator.Validator 45 + MentionsResolver *mentions.Resolver 46 + Notifier notify.Notifier 43 47 } 44 48 45 49 type processFunc func(ctx context.Context, e *jmodels.Event) error ··· 82 86 err = i.ingestIssue(ctx, e) 83 87 case tangled.RepoPullNSID: 84 88 err = i.ingestPull(ctx, e) 89 + case tangled.FeedCommentNSID: 90 + err = i.ingestComment(e) 85 91 case tangled.RepoIssueCommentNSID: 86 92 err = i.ingestIssueComment(e) 93 + case tangled.RepoPullCommentNSID: 94 + err = i.ingestPullComment(e) 87 95 case tangled.LabelDefinitionNSID: 88 96 err = i.ingestLabelDefinition(e) 89 97 case tangled.LabelOpNSID: ··· 1099 1107 return nil 1100 1108 } 1101 1109 1110 + // ingestIssueComment ingests legacy sh.tangled.repo.issue.comment deletions 1102 1111 func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 1112 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", e.Did, "rkey", e.Commit.RKey) 1113 + l.Info("ingesting record") 1114 + 1115 + switch e.Commit.Operation { 1116 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 1117 + // no-op. sh.tangled.repo.issue.comment is deprecated 1118 + 1119 + case jmodels.CommitOperationDelete: 1120 + if err := db.PurgeComments( 1121 + i.Db, 1122 + orm.FilterEq("did", e.Did), 1123 + orm.FilterEq("collection", e.Commit.Collection), 1124 + orm.FilterEq("rkey", e.Commit.RKey), 1125 + ); err != nil { 1126 + return fmt.Errorf("failed to delete comment record: %w", err) 1127 + } 1128 + } 1129 + 1130 + return nil 1131 + } 1132 + 1133 + // ingestPullComment ingests legacy sh.tangled.repo.pull.comment deletions 1134 + func (i *Ingester) ingestPullComment(e *jmodels.Event) error { 1135 + l := i.Logger.With("handler", "ingestPullComment", "nsid", e.Commit.Collection, "did", e.Did, "rkey", e.Commit.RKey) 1136 + l.Info("ingesting record") 1137 + 1138 + switch e.Commit.Operation { 1139 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 1140 + // no-op. sh.tangled.repo.pull.comment is deprecated 1141 + 1142 + case jmodels.CommitOperationDelete: 1143 + if err := db.PurgeComments( 1144 + i.Db, 1145 + orm.FilterEq("did", e.Did), 1146 + orm.FilterEq("collection", e.Commit.Collection), 1147 + orm.FilterEq("rkey", e.Commit.RKey), 1148 + ); err != nil { 1149 + return fmt.Errorf("failed to delete comment record: %w", err) 1150 + } 1151 + } 1152 + 1153 + return nil 1154 + } 1155 + 1156 + func (i *Ingester) ingestComment(e *jmodels.Event) error { 1103 1157 did := e.Did 1104 1158 rkey := e.Commit.RKey 1159 + cid := e.Commit.CID 1105 1160 1106 1161 var err error 1107 1162 1108 - l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 1163 + l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 1109 1164 l.Info("ingesting record") 1110 1165 1111 1166 ddb, ok := i.Db.Execer.(*db.DB) ··· 1113 1168 return fmt.Errorf("failed to index issue comment record, invalid db cast") 1114 1169 } 1115 1170 1171 + ctx := context.Background() 1172 + 1116 1173 switch e.Commit.Operation { 1117 1174 case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 1118 1175 raw := json.RawMessage(e.Commit.Record) 1119 - record := tangled.RepoIssueComment{} 1176 + record := tangled.FeedComment{} 1120 1177 err = json.Unmarshal(raw, &record) 1121 1178 if err != nil { 1122 1179 return fmt.Errorf("invalid record: %w", err) 1123 1180 } 1124 1181 1125 - comment, err := models.IssueCommentFromRecord(did, rkey, record) 1182 + comment, err := models.CommentFromRecord(syntax.DID(did), syntax.RecordKey(rkey), syntax.CID(cid), record) 1126 1183 if err != nil { 1127 1184 return fmt.Errorf("failed to parse comment from record: %w", err) 1128 1185 } 1129 1186 1130 - if err := i.Validator.ValidateIssueComment(comment); err != nil { 1187 + if err := comment.Validate(); err != nil { 1131 1188 return fmt.Errorf("failed to validate comment: %w", err) 1132 1189 } 1133 1190 1191 + var mentions []syntax.DID 1192 + var references []syntax.ATURI 1193 + if comment.Body.Original != nil { 1194 + mentions, references = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) 1195 + } 1196 + 1134 1197 tx, err := ddb.Begin() 1135 1198 if err != nil { 1136 1199 return fmt.Errorf("failed to start transaction: %w", err) 1137 1200 } 1138 1201 defer tx.Rollback() 1139 1202 1140 - _, err = db.AddIssueComment(tx, *comment) 1203 + err = db.PutComment(tx, comment, references) 1141 1204 if err != nil { 1142 - return fmt.Errorf("failed to create issue comment: %w", err) 1205 + return fmt.Errorf("failed to create comment: %w", err) 1143 1206 } 1144 1207 1145 - return tx.Commit() 1208 + if err := tx.Commit(); err != nil { 1209 + return err 1210 + } 1211 + 1212 + if e.Commit.Operation == jmodels.CommitOperationCreate { 1213 + i.Notifier.NewComment(ctx, comment, mentions) 1214 + } 1146 1215 1147 1216 case jmodels.CommitOperationDelete: 1148 - if err := db.DeleteIssueComments( 1217 + if err := db.DeleteComments( 1149 1218 ddb, 1150 1219 orm.FilterEq("did", did), 1220 + orm.FilterEq("collection", e.Commit.Collection), 1151 1221 orm.FilterEq("rkey", rkey), 1152 1222 ); err != nil { 1153 - return fmt.Errorf("failed to delete issue comment record: %w", err) 1223 + return fmt.Errorf("failed to delete comment record: %w", err) 1154 1224 } 1155 1225 1156 1226 return nil
+17 -413
appview/issues/issues.go
··· 13 13 "github.com/bluesky-social/indigo/atproto/atclient" 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 - "github.com/go-chi/chi/v5" 17 16 18 17 "tangled.org/core/api/tangled" 19 18 "tangled.org/core/appview/config" ··· 99 98 return 100 99 } 101 100 102 - reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 101 + entities := []syntax.ATURI{issue.AtUri()} 102 + for _, c := range issue.Comments { 103 + entities = append(entities, c.AtUri()) 104 + } 105 + reactions, err := db.ListReactionDisplayDataMap(rp.db, entities, 20) 103 106 if err != nil { 104 - l.Error("failed to get issue reactions", "err", err) 107 + l.Error("failed to get reactions", "err", err) 105 108 } 106 109 107 - userReactions := map[models.ReactionKind]bool{} 110 + var userReactions map[syntax.ATURI]map[models.ReactionKind]bool 108 111 if user != nil { 109 - userReactions = db.GetReactionStatusMap(rp.db, user.Active.Did, issue.AtUri()) 112 + userReactions, err = db.ListReactionStatusMap(rp.db, entities, syntax.DID(user.Active.Did)) 113 + if err != nil { 114 + l.Error("failed to get user reactions", "err", err) 115 + } 110 116 } 111 117 112 118 backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) ··· 132 138 defs[l.AtUri().String()] = &l 133 139 } 134 140 135 - rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 141 + err = rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 136 142 LoggedInUser: user, 137 143 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 138 144 Issue: issue, 139 - CommentList: issue.CommentList(), 145 + CommentList: models.NewCommentList(issue.Comments), 140 146 Backlinks: backlinks, 141 - Reactions: reactionMap, 147 + Reactions: reactions, 142 148 UserReacted: userReactions, 143 149 LabelDefs: defs, 144 150 }) 151 + if err != nil { 152 + l.Error("failed to render", "err", err) 153 + } 145 154 } 146 155 147 156 func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { ··· 386 395 http.Error(w, "forbidden", http.StatusUnauthorized) 387 396 return 388 397 } 389 - } 390 - 391 - func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 392 - l := rp.logger.With("handler", "NewIssueComment") 393 - user := rp.oauth.GetMultiAccountUser(r) 394 - f, err := rp.repoResolver.Resolve(r) 395 - if err != nil { 396 - l.Error("failed to get repo and knot", "err", err) 397 - return 398 - } 399 - 400 - issue, ok := r.Context().Value("issue").(*models.Issue) 401 - if !ok { 402 - l.Error("failed to get issue") 403 - rp.pages.Error404(w) 404 - return 405 - } 406 - 407 - body := r.FormValue("body") 408 - if body == "" { 409 - rp.pages.Notice(w, "issue", "Body is required") 410 - return 411 - } 412 - 413 - replyToUri := r.FormValue("reply-to") 414 - var replyTo *string 415 - if replyToUri != "" { 416 - replyTo = &replyToUri 417 - } 418 - 419 - mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 420 - 421 - comment := models.IssueComment{ 422 - Did: user.Active.Did, 423 - Rkey: tid.TID(), 424 - IssueAt: issue.AtUri().String(), 425 - ReplyTo: replyTo, 426 - Body: body, 427 - Created: time.Now(), 428 - Mentions: mentions, 429 - References: references, 430 - } 431 - if err = rp.validator.ValidateIssueComment(&comment); err != nil { 432 - l.Error("failed to validate comment", "err", err) 433 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 434 - return 435 - } 436 - record := comment.AsRecord() 437 - 438 - client, err := rp.oauth.AuthorizedClient(r) 439 - if err != nil { 440 - l.Error("failed to get authorized client", "err", err) 441 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 442 - return 443 - } 444 - 445 - // create a record first 446 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 447 - Collection: tangled.RepoIssueCommentNSID, 448 - Repo: comment.Did, 449 - Rkey: comment.Rkey, 450 - Record: &lexutil.LexiconTypeDecoder{ 451 - Val: &record, 452 - }, 453 - }) 454 - if err != nil { 455 - l.Error("failed to create comment", "err", err) 456 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 457 - return 458 - } 459 - atUri := resp.Uri 460 - defer func() { 461 - if err := rollbackRecord(context.Background(), atUri, client); err != nil { 462 - l.Error("rollback failed", "err", err) 463 - } 464 - }() 465 - 466 - tx, err := rp.db.Begin() 467 - if err != nil { 468 - l.Error("failed to start transaction", "err", err) 469 - rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 470 - return 471 - } 472 - defer tx.Rollback() 473 - 474 - commentId, err := db.AddIssueComment(tx, comment) 475 - if err != nil { 476 - l.Error("failed to create comment", "err", err) 477 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 478 - return 479 - } 480 - err = tx.Commit() 481 - if err != nil { 482 - l.Error("failed to commit transaction", "err", err) 483 - rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 484 - return 485 - } 486 - 487 - // reset atUri to make rollback a no-op 488 - atUri = "" 489 - 490 - // notify about the new comment 491 - comment.Id = commentId 492 - 493 - rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 494 - 495 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 496 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 497 - } 498 - 499 - func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 500 - l := rp.logger.With("handler", "IssueComment") 501 - user := rp.oauth.GetMultiAccountUser(r) 502 - 503 - issue, ok := r.Context().Value("issue").(*models.Issue) 504 - if !ok { 505 - l.Error("failed to get issue") 506 - rp.pages.Error404(w) 507 - return 508 - } 509 - 510 - commentId := chi.URLParam(r, "commentId") 511 - comments, err := db.GetIssueComments( 512 - rp.db, 513 - orm.FilterEq("id", commentId), 514 - ) 515 - if err != nil { 516 - l.Error("failed to fetch comment", "id", commentId) 517 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 518 - return 519 - } 520 - if len(comments) != 1 { 521 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 522 - http.Error(w, "invalid comment id", http.StatusBadRequest) 523 - return 524 - } 525 - comment := comments[0] 526 - 527 - rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 528 - LoggedInUser: user, 529 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 530 - Issue: issue, 531 - Comment: &comment, 532 - }) 533 - } 534 - 535 - func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 536 - l := rp.logger.With("handler", "EditIssueComment") 537 - user := rp.oauth.GetMultiAccountUser(r) 538 - 539 - issue, ok := r.Context().Value("issue").(*models.Issue) 540 - if !ok { 541 - l.Error("failed to get issue") 542 - rp.pages.Error404(w) 543 - return 544 - } 545 - 546 - commentId := chi.URLParam(r, "commentId") 547 - comments, err := db.GetIssueComments( 548 - rp.db, 549 - orm.FilterEq("id", commentId), 550 - ) 551 - if err != nil { 552 - l.Error("failed to fetch comment", "id", commentId) 553 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 554 - return 555 - } 556 - if len(comments) != 1 { 557 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 558 - http.Error(w, "invalid comment id", http.StatusBadRequest) 559 - return 560 - } 561 - comment := comments[0] 562 - 563 - if comment.Did != user.Active.Did { 564 - l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) 565 - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 566 - return 567 - } 568 - 569 - switch r.Method { 570 - case http.MethodGet: 571 - rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 572 - LoggedInUser: user, 573 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 574 - Issue: issue, 575 - Comment: &comment, 576 - }) 577 - case http.MethodPost: 578 - // extract form value 579 - newBody := r.FormValue("body") 580 - client, err := rp.oauth.AuthorizedClient(r) 581 - if err != nil { 582 - l.Error("failed to get authorized client", "err", err) 583 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 584 - return 585 - } 586 - 587 - now := time.Now() 588 - newComment := comment 589 - newComment.Body = newBody 590 - newComment.Edited = &now 591 - newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 592 - 593 - record := newComment.AsRecord() 594 - 595 - tx, err := rp.db.Begin() 596 - if err != nil { 597 - l.Error("failed to start transaction", "err", err) 598 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 599 - return 600 - } 601 - defer tx.Rollback() 602 - 603 - _, err = db.AddIssueComment(tx, newComment) 604 - if err != nil { 605 - l.Error("failed to perform update-description query", "err", err) 606 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 607 - return 608 - } 609 - tx.Commit() 610 - 611 - // rkey is optional, it was introduced later 612 - if newComment.Rkey != "" { 613 - // update the record on pds 614 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Active.Did, comment.Rkey) 615 - if err != nil { 616 - l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 617 - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 618 - return 619 - } 620 - 621 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 622 - Collection: tangled.RepoIssueCommentNSID, 623 - Repo: user.Active.Did, 624 - Rkey: newComment.Rkey, 625 - SwapRecord: ex.Cid, 626 - Record: &lexutil.LexiconTypeDecoder{ 627 - Val: &record, 628 - }, 629 - }) 630 - if err != nil { 631 - l.Error("failed to update record on PDS", "err", err) 632 - } 633 - } 634 - 635 - // return new comment body with htmx 636 - rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 637 - LoggedInUser: user, 638 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 639 - Issue: issue, 640 - Comment: &newComment, 641 - }) 642 - } 643 - } 644 - 645 - func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 646 - l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 647 - user := rp.oauth.GetMultiAccountUser(r) 648 - 649 - issue, ok := r.Context().Value("issue").(*models.Issue) 650 - if !ok { 651 - l.Error("failed to get issue") 652 - rp.pages.Error404(w) 653 - return 654 - } 655 - 656 - commentId := chi.URLParam(r, "commentId") 657 - comments, err := db.GetIssueComments( 658 - rp.db, 659 - orm.FilterEq("id", commentId), 660 - ) 661 - if err != nil { 662 - l.Error("failed to fetch comment", "id", commentId) 663 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 664 - return 665 - } 666 - if len(comments) != 1 { 667 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 668 - http.Error(w, "invalid comment id", http.StatusBadRequest) 669 - return 670 - } 671 - comment := comments[0] 672 - 673 - rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 674 - LoggedInUser: user, 675 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 676 - Issue: issue, 677 - Comment: &comment, 678 - }) 679 - } 680 - 681 - func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 682 - l := rp.logger.With("handler", "ReplyIssueComment") 683 - user := rp.oauth.GetMultiAccountUser(r) 684 - 685 - issue, ok := r.Context().Value("issue").(*models.Issue) 686 - if !ok { 687 - l.Error("failed to get issue") 688 - rp.pages.Error404(w) 689 - return 690 - } 691 - 692 - commentId := chi.URLParam(r, "commentId") 693 - comments, err := db.GetIssueComments( 694 - rp.db, 695 - orm.FilterEq("id", commentId), 696 - ) 697 - if err != nil { 698 - l.Error("failed to fetch comment", "id", commentId) 699 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 700 - return 701 - } 702 - if len(comments) != 1 { 703 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 704 - http.Error(w, "invalid comment id", http.StatusBadRequest) 705 - return 706 - } 707 - comment := comments[0] 708 - 709 - rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 710 - LoggedInUser: user, 711 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 712 - Issue: issue, 713 - Comment: &comment, 714 - }) 715 - } 716 - 717 - func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 718 - l := rp.logger.With("handler", "DeleteIssueComment") 719 - user := rp.oauth.GetMultiAccountUser(r) 720 - 721 - issue, ok := r.Context().Value("issue").(*models.Issue) 722 - if !ok { 723 - l.Error("failed to get issue") 724 - rp.pages.Error404(w) 725 - return 726 - } 727 - 728 - commentId := chi.URLParam(r, "commentId") 729 - comments, err := db.GetIssueComments( 730 - rp.db, 731 - orm.FilterEq("id", commentId), 732 - ) 733 - if err != nil { 734 - l.Error("failed to fetch comment", "id", commentId) 735 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 736 - return 737 - } 738 - if len(comments) != 1 { 739 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 740 - http.Error(w, "invalid comment id", http.StatusBadRequest) 741 - return 742 - } 743 - comment := comments[0] 744 - 745 - if comment.Did != user.Active.Did { 746 - l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) 747 - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 748 - return 749 - } 750 - 751 - if comment.Deleted != nil { 752 - http.Error(w, "comment already deleted", http.StatusBadRequest) 753 - return 754 - } 755 - 756 - // optimistic deletion 757 - deleted := time.Now() 758 - err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 759 - if err != nil { 760 - l.Error("failed to delete comment", "err", err) 761 - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 762 - return 763 - } 764 - 765 - // delete from pds 766 - if comment.Rkey != "" { 767 - client, err := rp.oauth.AuthorizedClient(r) 768 - if err != nil { 769 - l.Error("failed to get authorized client", "err", err) 770 - rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 771 - return 772 - } 773 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 774 - Collection: tangled.RepoIssueCommentNSID, 775 - Repo: user.Active.Did, 776 - Rkey: comment.Rkey, 777 - }) 778 - if err != nil { 779 - l.Error("failed to delete from PDS", "err", err) 780 - } 781 - } 782 - 783 - // optimistic update for htmx 784 - comment.Body = "" 785 - comment.Deleted = &deleted 786 - 787 - // htmx fragment of comment after deletion 788 - rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 789 - LoggedInUser: user, 790 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 791 - Issue: issue, 792 - Comment: &comment, 793 - }) 794 398 } 795 399 796 400 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
-9
appview/issues/router.go
··· 21 21 // authenticated routes 22 22 r.Group(func(r chi.Router) { 23 23 r.Use(middleware.AuthMiddleware(i.oauth)) 24 - r.Post("/comment", i.NewIssueComment) 25 - r.Route("/comment/{commentId}/", func(r chi.Router) { 26 - r.Get("/", i.IssueComment) 27 - r.Delete("/", i.DeleteIssueComment) 28 - r.Get("/edit", i.EditIssueComment) 29 - r.Post("/edit", i.EditIssueComment) 30 - r.Get("/reply", i.ReplyIssueComment) 31 - r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 32 - }) 33 24 r.Get("/edit", i.EditIssue) 34 25 r.Post("/edit", i.EditIssue) 35 26 r.Delete("/", i.DeleteIssue)
+151
appview/migration/migrate_add_repo_did.go
··· 1 + package migration 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/atclient" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + lexutil "github.com/bluesky-social/indigo/lex/util" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/db" 14 + ) 15 + 16 + func (s *Migration) migrateAddRepoDid(ctx context.Context, client *atclient.APIClient, did syntax.DID, record syntax.ATURI) error { 17 + // TODO: use agnostic.RepoGetRecord instead 18 + ex, err := comatproto.RepoGetRecord(ctx, client, "", record.Collection().String(), did.String(), record.RecordKey().String()) 19 + if err != nil { 20 + return fmt.Errorf("pds: %w", err) 21 + } 22 + 23 + val := ex.Value.Val 24 + 25 + switch record.Collection() { 26 + case tangled.RepoNSID: 27 + rec, ok := val.(*tangled.Repo) 28 + if !ok { 29 + return fmt.Errorf("unexpected type for repo record") 30 + } 31 + repo, err := db.GetRepoByAtUri(s.db, record.String()) 32 + if err != nil { 33 + return fmt.Errorf("db: failed to query repo: %w", err) 34 + } 35 + rec.RepoDid = &repo.RepoDid 36 + 37 + case tangled.RepoIssueNSID: 38 + rec, ok := val.(*tangled.RepoIssue) 39 + if !ok { 40 + return fmt.Errorf("unexpected type for issue record") 41 + } 42 + if rec.Repo != nil { 43 + repoAt := *rec.Repo 44 + repo, err := db.GetRepoByAtUri(s.db, repoAt) 45 + if err != nil { 46 + return fmt.Errorf("db: failed to query repo: %w", err) 47 + } 48 + rec.RepoDid = &repo.RepoDid 49 + } 50 + 51 + case tangled.RepoPullNSID: 52 + rec, ok := val.(*tangled.RepoPull) 53 + if !ok { 54 + return fmt.Errorf("unexpected type for pull record") 55 + } 56 + if rec.Target != nil && rec.Target.Repo != nil { 57 + repoAt := *rec.Target.Repo 58 + repo, err := db.GetRepoByAtUri(s.db, repoAt) 59 + if err != nil { 60 + return fmt.Errorf("db: failed to query repo: %w", err) 61 + } 62 + rec.Target.RepoDid = &repo.RepoDid 63 + } 64 + if rec.Source != nil && rec.Source.Repo != nil { 65 + repoAt := *rec.Source.Repo 66 + repo, err := db.GetRepoByAtUri(s.db, repoAt) 67 + if err != nil { 68 + return fmt.Errorf("db: failed to query repo: %w", err) 69 + } 70 + rec.Source.RepoDid = &repo.RepoDid 71 + } 72 + 73 + case tangled.RepoCollaboratorNSID: 74 + rec, ok := val.(*tangled.RepoCollaborator) 75 + if !ok { 76 + return fmt.Errorf("unexpected type for collaborator record") 77 + } 78 + if rec.Repo != nil { 79 + repoAt := *rec.Repo 80 + repo, err := db.GetRepoByAtUri(s.db, repoAt) 81 + if err != nil { 82 + return fmt.Errorf("db: failed to query repo: %w", err) 83 + } 84 + rec.RepoDid = &repo.RepoDid 85 + } 86 + 87 + case tangled.RepoArtifactNSID: 88 + rec, ok := val.(*tangled.RepoArtifact) 89 + if !ok { 90 + return fmt.Errorf("unexpected type for artifact record") 91 + } 92 + if rec.Repo != nil { 93 + repoAt := *rec.Repo 94 + repo, err := db.GetRepoByAtUri(s.db, repoAt) 95 + if err != nil { 96 + return fmt.Errorf("db: failed to query repo: %w", err) 97 + } 98 + rec.RepoDid = &repo.RepoDid 99 + } 100 + 101 + case tangled.FeedStarNSID: 102 + rec, ok := val.(*tangled.FeedStar) 103 + if !ok { 104 + return fmt.Errorf("unexpected type for star record") 105 + } 106 + if rec.Subject != nil { 107 + repoAt := *rec.Subject 108 + repo, err := db.GetRepoByAtUri(s.db, repoAt) 109 + if err != nil { 110 + return fmt.Errorf("db: failed to query repo: %w", err) 111 + } 112 + rec.SubjectDid = &repo.RepoDid 113 + } 114 + 115 + case tangled.ActorProfileNSID: 116 + rec, ok := val.(*tangled.ActorProfile) 117 + if !ok { 118 + return fmt.Errorf("unexpected type for profile record") 119 + } 120 + rewritten := make([]string, 0, len(rec.PinnedRepositories)) 121 + for _, pin := range rec.PinnedRepositories { 122 + if strings.HasPrefix(pin, "did:") { 123 + rewritten = append(rewritten, pin) 124 + continue 125 + } 126 + repo, repoErr := db.GetRepoByAtUri(s.db, pin) 127 + if repoErr != nil || repo.RepoDid == "" { 128 + rewritten = append(rewritten, pin) 129 + continue 130 + } 131 + rewritten = append(rewritten, repo.RepoDid) 132 + } 133 + rec.PinnedRepositories = rewritten 134 + 135 + default: 136 + return fmt.Errorf("unexpected collection: '%s'", record.Collection()) 137 + } 138 + 139 + _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 140 + Repo: did.String(), 141 + Collection: record.Collection().String(), 142 + Rkey: record.RecordKey().String(), 143 + SwapRecord: ex.Cid, 144 + Record: &lexutil.LexiconTypeDecoder{Val: val}, 145 + }) 146 + if err != nil { 147 + return fmt.Errorf("put record: %w", err) 148 + } 149 + 150 + return nil 151 + }
+114
appview/migration/migrate_use_feed_comment.go
··· 1 + package migration 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/bluesky-social/indigo/api/agnostic" 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/atclient" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/lex/util" 12 + "github.com/bluesky-social/indigo/xrpc" 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/orm" 16 + ) 17 + 18 + 19 + func (s *Migration) migrateUseFeedComment(ctx context.Context, client *atclient.APIClient, did syntax.DID, record syntax.ATURI) error { 20 + l := s.logger.With("aturi", record) 21 + l.Debug("migrating record") 22 + 23 + switch record.Collection() { 24 + case tangled.RepoIssueCommentNSID: 25 + case tangled.RepoPullCommentNSID: 26 + default: 27 + return fmt.Errorf("unexpected collection: '%s'", record.Collection()) 28 + } 29 + 30 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", record)) 31 + if err != nil { 32 + return fmt.Errorf("db: %w", err) 33 + } 34 + 35 + comment.Collection = tangled.FeedCommentNSID 36 + 37 + // only update from DB if comment is deleted 38 + if comment.Deleted != nil { 39 + l.Info("skipping pds migration for deleted record") 40 + 41 + return nil 42 + } 43 + 44 + // fill missing reference CIDs 45 + if comment.Subject.Cid == "" { 46 + cid, err := s.getRecordCid(ctx, syntax.ATURI(comment.Subject.Uri)) 47 + if err != nil { 48 + return fmt.Errorf("pds: getRecordCid for subject.uri: %w", err) 49 + } 50 + comment.Subject.Cid = cid.String() 51 + } 52 + if comment.ReplyTo != nil && comment.ReplyTo.Cid == "" { 53 + uri, err := syntax.ParseATURI(comment.ReplyTo.Uri) 54 + if err != nil { 55 + return fmt.Errorf("invalid replyTo.uri: %w", err) 56 + } 57 + 58 + // assume parent comment is already migrated to `sh.tangled.feed.comment`. 59 + // fail if it isn't ready 60 + uri = syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", uri.Authority(), tangled.FeedCommentNSID, uri.RecordKey())) 61 + 62 + cid, err := s.getRecordCid(ctx, uri) 63 + if err != nil { 64 + return fmt.Errorf("pds: getRecordCid for replyTo.uri: %w", err) 65 + } 66 + comment.ReplyTo.Uri = uri.String() 67 + comment.ReplyTo.Cid = cid.String() 68 + } 69 + 70 + // use same rkey for new record 71 + rkey := record.RecordKey().String() 72 + 73 + if _, err := comatproto.RepoApplyWrites(ctx, client, &comatproto.RepoApplyWrites_Input{ 74 + Repo: did.String(), 75 + Writes: []*comatproto.RepoApplyWrites_Input_Writes_Elem{ 76 + {RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 77 + Collection: record.Collection().String(), 78 + Rkey: rkey, 79 + }}, 80 + {RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 81 + Collection: tangled.FeedCommentNSID, 82 + Rkey: &rkey, 83 + Value: &util.LexiconTypeDecoder{Val: comment.AsRecord()}, 84 + }}, 85 + }, 86 + }); err != nil { 87 + return fmt.Errorf("pds: applyWrites: %w", err) 88 + } 89 + 90 + return nil 91 + } 92 + 93 + func (s *Migration) getRecordCid(ctx context.Context, uri syntax.ATURI) (syntax.CID, error) { 94 + ident, err := s.dir.Lookup(ctx, uri.Authority()) 95 + if err != nil { 96 + return "", err 97 + } 98 + 99 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 100 + out, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) 101 + if err != nil { 102 + return "", err 103 + } 104 + if out.Cid == nil { 105 + return "", fmt.Errorf("record CID is empty") 106 + } 107 + 108 + cid, err := syntax.ParseCID(*out.Cid) 109 + if err != nil { 110 + return "", err 111 + } 112 + 113 + return cid, nil 114 + }
+102
appview/migration/migration.go
··· 1 + package migration 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "strings" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/atclient" 12 + "github.com/bluesky-social/indigo/atproto/identity" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + 15 + "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/models" 17 + "tangled.org/core/appview/oauth" 18 + ) 19 + 20 + type Migration struct { 21 + db *db.DB 22 + oauth *oauth.OAuth 23 + dir identity.Directory 24 + logger *slog.Logger 25 + } 26 + 27 + func NewMigration(db *db.DB, oauth *oauth.OAuth, dir identity.Directory, logger *slog.Logger) *Migration { 28 + return &Migration{ 29 + db, oauth, dir, logger, 30 + } 31 + } 32 + 33 + func (s *Migration) BackgroundMigrationMiddleware(next http.Handler) http.Handler { 34 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 35 + defer next.ServeHTTP(w, r) 36 + 37 + client, err := s.oauth.AuthorizedClient(r) 38 + if err != nil { 39 + return 40 + } 41 + if client.AccountDID == nil { 42 + return 43 + } 44 + 45 + go s.runPendingMigrations(context.Background(), *client.AccountDID, client) 46 + }) 47 + } 48 + 49 + func (s *Migration) runPendingMigrations(ctx context.Context, did syntax.DID, client *atclient.APIClient) { 50 + l := s.logger.With("did", did) 51 + migrations, err := db.ListPendingPdsRecordMigrations(ctx, s.db, did) 52 + if err != nil { 53 + l.Error("failed to query pending migrations", "err", err) 54 + return 55 + } 56 + 57 + for _, migration := range migrations { 58 + if err := s.migrate(ctx, client, migration); err != nil { 59 + l.Error("migration failed", "err", err) 60 + } 61 + } 62 + } 63 + 64 + func (s *Migration) migrate(ctx context.Context, client *atclient.APIClient, migration *models.PDSMigration) error { 65 + l := s.logger.With( 66 + "name", migration.Name, 67 + "aturi", migration.RecordAtUri(), 68 + ) 69 + 70 + var err error 71 + switch migration.Name { 72 + case "add-repo-did": 73 + err = s.migrateAddRepoDid(ctx, client, migration.Did, migration.RecordAtUri()) 74 + case "use-feed-comment": 75 + err = s.migrateUseFeedComment(ctx, client, migration.Did, migration.RecordAtUri()) 76 + default: 77 + return fmt.Errorf("unexpected migration name %s", migration.Name) 78 + } 79 + 80 + if err == nil { 81 + l.Info("migrated") 82 + migration.Status = models.PDSMigrationStatusDone 83 + } else { 84 + l.Warn("failed to migrate", "err", err) 85 + 86 + errMsg := err.Error() 87 + var retryCount = migration.RetryCount + 1 88 + var retryAfter = time.Now().Add(3 * time.Second).Unix() 89 + 90 + // remove null bytes 91 + errMsg = strings.ReplaceAll(errMsg, "\x00", "") 92 + 93 + migration.Status = models.PDSMigrationStatusPending 94 + migration.ErrorMsg = &errMsg 95 + migration.RetryCount = retryCount 96 + migration.RetryAfter = retryAfter 97 + } 98 + if err := db.UpdatePdsRecordMigration(ctx, s.db, migration); err != nil { 99 + return fmt.Errorf("failed to update migration status: %w", err) 100 + } 101 + return nil 102 + }
+230
appview/models/comment.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "sort" 6 + "strings" 7 + "time" 8 + 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + typegen "github.com/whyrusleeping/cbor-gen" 12 + "tangled.org/core/api/tangled" 13 + ) 14 + 15 + type Comment struct { 16 + Id int64 17 + 18 + Did syntax.DID 19 + Collection syntax.NSID 20 + Rkey syntax.RecordKey 21 + Cid syntax.CID 22 + 23 + // record content 24 + Subject comatproto.RepoStrongRef 25 + Body tangled.MarkupMarkdown // markup body type. only markdown is supported right now 26 + Created time.Time 27 + ReplyTo *comatproto.RepoStrongRef // (optional) parent comment 28 + PullRoundIdx *int // (optional) pull round number used when subject is sh.tangled.repo.pull 29 + 30 + // store on db, but not on PDS 31 + Edited *time.Time 32 + Deleted *time.Time 33 + } 34 + 35 + func (c Comment) AtUri() syntax.ATURI { 36 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, c.Collection, c.Rkey)) 37 + } 38 + 39 + func (c Comment) StrongRef() comatproto.RepoStrongRef { 40 + return comatproto.RepoStrongRef{ 41 + Uri: c.AtUri().String(), 42 + Cid: c.Cid.String(), 43 + } 44 + } 45 + 46 + func (c Comment) AsRecord() typegen.CBORMarshaler { 47 + // can't convert to record for legacy types 48 + if c.Collection != tangled.FeedCommentNSID { 49 + return nil 50 + } 51 + var pullRoundIdx int64 52 + if c.PullRoundIdx != nil { 53 + pullRoundIdx = int64(*c.PullRoundIdx) 54 + } 55 + return &tangled.FeedComment{ 56 + Subject: &c.Subject, 57 + Body: &tangled.FeedComment_Body{MarkupMarkdown: &c.Body}, 58 + CreatedAt: c.Created.Format(time.RFC3339), 59 + ReplyTo: c.ReplyTo, 60 + PullRoundIdx: &pullRoundIdx, 61 + } 62 + } 63 + 64 + func (c Comment) EditableBody() string { 65 + if c.Body.Original != nil { 66 + return *c.Body.Original 67 + } 68 + return c.Body.Text 69 + } 70 + 71 + func (c Comment) IsLegacy() bool { 72 + return c.Collection != tangled.FeedCommentNSID 73 + } 74 + 75 + func (c *Comment) IsTopLevel() bool { 76 + return c.ReplyTo == nil 77 + } 78 + 79 + func (c *Comment) IsReply() bool { 80 + return c.ReplyTo != nil 81 + } 82 + 83 + func (c *Comment) Validate() error { 84 + // TODO: sanitize the body and then trim space 85 + if sb := strings.TrimSpace(c.Body.Text); sb == "" { 86 + return fmt.Errorf("body is empty after HTML sanitization") 87 + } 88 + 89 + // if it's for PR, PullSubmissionId should not be nil 90 + subjectAt, err := syntax.ParseATURI(c.Subject.Uri) 91 + if err != nil { 92 + return fmt.Errorf("subject.uri is not valid at-uri: %w", err) 93 + } 94 + if subjectAt.Collection().String() == tangled.RepoPullNSID { 95 + if c.PullRoundIdx == nil { 96 + return fmt.Errorf("pullSubmissionId should not be nil when subject is sh.tangled.repo.pull") 97 + } 98 + } 99 + return nil 100 + } 101 + 102 + func CommentFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.FeedComment) (*Comment, error) { 103 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 104 + if err != nil { 105 + created = time.Now() 106 + } 107 + 108 + if record.Subject == nil { 109 + return nil, fmt.Errorf("subject can't be nil") 110 + } 111 + subjectAt, err := syntax.ParseATURI(record.Subject.Uri) 112 + if err != nil { 113 + return nil, fmt.Errorf("invalid subject uri: %w", err) 114 + } 115 + if _, err = syntax.ParseCID(record.Subject.Cid); err != nil { 116 + return nil, fmt.Errorf("invalid subject cid: %w", err) 117 + } 118 + 119 + if subjectAt.Collection() == tangled.RepoPullNSID { 120 + if record.PullRoundIdx == nil { 121 + return nil, fmt.Errorf("pullRoundIdx can't be nil when subject is sh.tangled.repo.pull") 122 + } 123 + } 124 + 125 + if record.Body == nil { 126 + return nil, fmt.Errorf("body can't be nil") 127 + } 128 + if record.Body.MarkupMarkdown == nil { 129 + return nil, fmt.Errorf("body should be markdown type") 130 + } 131 + 132 + if record.ReplyTo != nil { 133 + if _, err = syntax.ParseATURI(record.ReplyTo.Uri); err != nil { 134 + return nil, fmt.Errorf("invalid replyTo uri: %w", err) 135 + } 136 + if _, err = syntax.ParseCID(record.ReplyTo.Cid); err != nil { 137 + return nil, fmt.Errorf("invalid replyTo cid: %w", err) 138 + } 139 + } 140 + 141 + var pullRoundIdx *int 142 + if record.PullRoundIdx != nil { 143 + pullRoundIdx = new(int) 144 + *pullRoundIdx = int(*record.PullRoundIdx) 145 + } 146 + 147 + return &Comment{ 148 + Did: did, 149 + Collection: tangled.FeedCommentNSID, 150 + Rkey: rkey, 151 + Cid: cid, 152 + 153 + Subject: *record.Subject, 154 + Body: *record.Body.MarkupMarkdown, 155 + Created: created, 156 + ReplyTo: record.ReplyTo, 157 + PullRoundIdx: pullRoundIdx, 158 + }, nil 159 + } 160 + 161 + type CommentListItem struct { 162 + Self *Comment 163 + Replies []*Comment 164 + } 165 + 166 + func (it *CommentListItem) Participants() []syntax.DID { 167 + participantSet := make(map[syntax.DID]struct{}) 168 + participants := []syntax.DID{} 169 + 170 + addParticipant := func(did syntax.DID) { 171 + if _, exists := participantSet[did]; !exists { 172 + participantSet[did] = struct{}{} 173 + participants = append(participants, did) 174 + } 175 + } 176 + 177 + addParticipant(syntax.DID(it.Self.Did)) 178 + 179 + for _, c := range it.Replies { 180 + addParticipant(syntax.DID(c.Did)) 181 + } 182 + 183 + return participants 184 + } 185 + 186 + func NewCommentList(comments []Comment) []CommentListItem { 187 + // Create a map to quickly find comments by their aturi 188 + toplevel := make(map[syntax.ATURI]*CommentListItem) 189 + var replies []*Comment 190 + 191 + // collect top level comments into the map 192 + for _, comment := range comments { 193 + if comment.IsTopLevel() { 194 + toplevel[comment.AtUri()] = &CommentListItem{ 195 + Self: &comment, 196 + } 197 + } else { 198 + replies = append(replies, &comment) 199 + } 200 + } 201 + 202 + for _, r := range replies { 203 + if r.ReplyTo == nil { 204 + continue 205 + } 206 + if parent, exists := toplevel[syntax.ATURI(r.ReplyTo.Uri)]; exists { 207 + parent.Replies = append(parent.Replies, r) 208 + } 209 + } 210 + 211 + var listing []CommentListItem 212 + for _, v := range toplevel { 213 + listing = append(listing, *v) 214 + } 215 + 216 + // sort everything 217 + sortFunc := func(a, b *Comment) bool { 218 + return a.Created.Before(b.Created) 219 + } 220 + sort.Slice(listing, func(i, j int) bool { 221 + return sortFunc(listing[i].Self, listing[j].Self) 222 + }) 223 + for _, r := range listing { 224 + sort.Slice(r.Replies, func(i, j int) bool { 225 + return sortFunc(r.Replies[i], r.Replies[j]) 226 + }) 227 + } 228 + 229 + return listing 230 + }
+2 -153
appview/models/issue.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "sort" 6 5 "time" 7 6 8 7 "github.com/bluesky-social/indigo/atproto/syntax" ··· 26 25 27 26 // optionally, populate this when querying for reverse mappings 28 27 // like comment counts, parent repo etc. 29 - Comments []IssueComment 28 + Comments []Comment 30 29 Labels LabelState 31 30 Repo *Repo 32 31 } ··· 66 65 return "closed" 67 66 } 68 67 69 - type CommentListItem struct { 70 - Self *IssueComment 71 - Replies []*IssueComment 72 - } 73 - 74 - func (it *CommentListItem) Participants() []syntax.DID { 75 - participantSet := make(map[syntax.DID]struct{}) 76 - participants := []syntax.DID{} 77 - 78 - addParticipant := func(did syntax.DID) { 79 - if _, exists := participantSet[did]; !exists { 80 - participantSet[did] = struct{}{} 81 - participants = append(participants, did) 82 - } 83 - } 84 - 85 - addParticipant(syntax.DID(it.Self.Did)) 86 - 87 - for _, c := range it.Replies { 88 - addParticipant(syntax.DID(c.Did)) 89 - } 90 - 91 - return participants 92 - } 93 - 94 - func (i *Issue) CommentList() []CommentListItem { 95 - // Create a map to quickly find comments by their aturi 96 - toplevel := make(map[string]*CommentListItem) 97 - var replies []*IssueComment 98 - 99 - // collect top level comments into the map 100 - for _, comment := range i.Comments { 101 - if comment.IsTopLevel() { 102 - toplevel[comment.AtUri().String()] = &CommentListItem{ 103 - Self: &comment, 104 - } 105 - } else { 106 - replies = append(replies, &comment) 107 - } 108 - } 109 - 110 - for _, r := range replies { 111 - parentAt := *r.ReplyTo 112 - if parent, exists := toplevel[parentAt]; exists { 113 - parent.Replies = append(parent.Replies, r) 114 - } 115 - } 116 - 117 - var listing []CommentListItem 118 - for _, v := range toplevel { 119 - listing = append(listing, *v) 120 - } 121 - 122 - // sort everything 123 - sortFunc := func(a, b *IssueComment) bool { 124 - return a.Created.Before(b.Created) 125 - } 126 - sort.Slice(listing, func(i, j int) bool { 127 - return sortFunc(listing[i].Self, listing[j].Self) 128 - }) 129 - for _, r := range listing { 130 - sort.Slice(r.Replies, func(i, j int) bool { 131 - return sortFunc(r.Replies[i], r.Replies[j]) 132 - }) 133 - } 134 - 135 - return listing 136 - } 137 - 138 68 func (i *Issue) Participants() []string { 139 69 participantSet := make(map[string]struct{}) 140 70 participants := []string{} ··· 149 79 addParticipant(i.Did) 150 80 151 81 for _, c := range i.Comments { 152 - addParticipant(c.Did) 82 + addParticipant(c.Did.String()) 153 83 } 154 84 155 85 return participants ··· 181 111 Open: true, // new issues are open by default 182 112 } 183 113 } 184 - 185 - type IssueComment struct { 186 - Id int64 187 - Did string 188 - Rkey string 189 - IssueAt string 190 - ReplyTo *string 191 - Body string 192 - Created time.Time 193 - Edited *time.Time 194 - Deleted *time.Time 195 - Mentions []syntax.DID 196 - References []syntax.ATURI 197 - } 198 - 199 - func (i *IssueComment) AtUri() syntax.ATURI { 200 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 201 - } 202 - 203 - func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 204 - mentions := make([]string, len(i.Mentions)) 205 - for i, did := range i.Mentions { 206 - mentions[i] = string(did) 207 - } 208 - references := make([]string, len(i.References)) 209 - for i, uri := range i.References { 210 - references[i] = string(uri) 211 - } 212 - return tangled.RepoIssueComment{ 213 - Body: i.Body, 214 - Issue: i.IssueAt, 215 - CreatedAt: i.Created.Format(time.RFC3339), 216 - ReplyTo: i.ReplyTo, 217 - Mentions: mentions, 218 - References: references, 219 - } 220 - } 221 - 222 - func (i *IssueComment) IsTopLevel() bool { 223 - return i.ReplyTo == nil 224 - } 225 - 226 - func (i *IssueComment) IsReply() bool { 227 - return i.ReplyTo != nil 228 - } 229 - 230 - func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 231 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 232 - if err != nil { 233 - created = time.Now() 234 - } 235 - 236 - ownerDid := did 237 - 238 - if _, err = syntax.ParseATURI(record.Issue); err != nil { 239 - return nil, err 240 - } 241 - 242 - i := record 243 - mentions := make([]syntax.DID, len(record.Mentions)) 244 - for i, did := range record.Mentions { 245 - mentions[i] = syntax.DID(did) 246 - } 247 - references := make([]syntax.ATURI, len(record.References)) 248 - for i, uri := range i.References { 249 - references[i] = syntax.ATURI(uri) 250 - } 251 - 252 - comment := IssueComment{ 253 - Did: ownerDid, 254 - Rkey: rkey, 255 - Body: record.Body, 256 - IssueAt: record.Issue, 257 - ReplyTo: record.ReplyTo, 258 - Created: created, 259 - Mentions: mentions, 260 - References: references, 261 - } 262 - 263 - return &comment, nil 264 - }
+37
appview/models/migration.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type PDSRecordMigration struct { 10 + Did syntax.DID 11 + Name string // name of the migration 12 + Records []syntax.ATURI // records that need a migration 13 + ErrorMsg *string // error message from previous attempt 14 + } 15 + 16 + type PDSMigration struct { 17 + Name string // name of the migration 18 + Did syntax.DID // record owner 19 + Collection syntax.NSID // record collection 20 + Rkey syntax.RecordKey // record rkey 21 + Status PDSMigrationStatus 22 + ErrorMsg *string // error message from previous attempt 23 + RetryCount int 24 + RetryAfter int64 // Unix timestamp (seconds) 25 + } 26 + 27 + type PDSMigrationStatus string 28 + 29 + const ( 30 + PDSMigrationStatusPending PDSMigrationStatus = "pending" 31 + PDSMigrationStatusRunning PDSMigrationStatus = "running" 32 + PDSMigrationStatusDone PDSMigrationStatus = "done" 33 + ) 34 + 35 + func (m *PDSMigration) RecordAtUri() syntax.ATURI { 36 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", m.Did, m.Collection, m.Rkey)) 37 + }
+2 -28
appview/models/pull.go
··· 274 274 Blob lexutil.LexBlob 275 275 Patch string 276 276 Combined string 277 - Comments []PullComment 277 + Comments []Comment 278 278 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 279 279 280 280 // meta 281 281 Created time.Time 282 - } 283 - 284 - type PullComment struct { 285 - // ids 286 - ID int 287 - PullId int 288 - SubmissionId int 289 - 290 - // at ids 291 - RepoAt string 292 - OwnerDid string 293 - CommentAt string 294 - 295 - // content 296 - Body string 297 - 298 - // meta 299 - Mentions []syntax.DID 300 - References []syntax.ATURI 301 - 302 - // meta 303 - Created time.Time 304 - } 305 - 306 - func (p *PullComment) AtUri() syntax.ATURI { 307 - return syntax.ATURI(p.CommentAt) 308 282 } 309 283 310 284 func (p *Pull) TotalComments() int { ··· 426 400 addParticipant(s.PullAt.Authority().String()) 427 401 428 402 for _, c := range s.Comments { 429 - addParticipant(c.OwnerDid) 403 + addParticipant(c.Did.String()) 430 404 } 431 405 432 406 return participants
+137 -130
appview/notify/db/db.go
··· 80 80 // no-op 81 81 } 82 82 83 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 83 + func (n *databaseNotifier) NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 84 84 l := log.FromContext(ctx) 85 85 86 - collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 87 - if err != nil { 88 - l.Error("failed to fetch collaborators", "err", err) 89 - return 90 - } 86 + var ( 87 + // built the recipients list: 88 + // - the owner of the repo 89 + // - | if the comment is a reply -> everybody on that thread 90 + // | if the comment is a top level -> just the issue owner 91 + // - remove mentioned users from the recipients list 92 + recipients = sets.New[syntax.DID]() 93 + entityType string 94 + entityId string 95 + repoId *int64 96 + issueId *int64 97 + pullId *int64 98 + ) 99 + 100 + subjectAt := syntax.ATURI(comment.Subject.Uri) 101 + 102 + switch subjectAt.Collection() { 103 + case tangled.RepoIssueNSID: 104 + issues, err := db.GetIssues( 105 + n.db, 106 + orm.FilterEq("at_uri", subjectAt), 107 + ) 108 + if err != nil { 109 + l.Error("failed to get issues", "err", err) 110 + return 111 + } 112 + if len(issues) == 0 { 113 + l.Error("no issue found", "subject", comment.Subject) 114 + return 115 + } 116 + issue := issues[0] 117 + 118 + recipients.Insert(syntax.DID(issue.Repo.Did)) 119 + if comment.IsReply() { 120 + // if this comment is a reply, then notify everybody in that thread 121 + parent := *comment.ReplyTo 122 + 123 + // find the parent thread, and add all DIDs from here to the recipient list 124 + for _, t := range models.NewCommentList(issue.Comments) { 125 + if t.Self.AtUri() == syntax.ATURI(parent.Uri) { 126 + for _, p := range t.Participants() { 127 + recipients.Insert(p) 128 + } 129 + } 130 + } 131 + } else { 132 + // not a reply, notify just the issue author 133 + recipients.Insert(syntax.DID(issue.Did)) 134 + } 91 135 92 - // build the recipients list 93 - // - owner of the repo 94 - // - collaborators in the repo 95 - // - remove users already mentioned 96 - recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 97 - for _, c := range collaborators { 98 - recipients.Insert(c.SubjectDid) 136 + entityType = "issue" 137 + entityId = issue.AtUri().String() 138 + repoId = &issue.Repo.Id 139 + issueId = &issue.Id 140 + 141 + for _, m := range mentions { 142 + recipients.Remove(m) 143 + } 144 + 145 + n.notifyEvent( 146 + ctx, 147 + comment.Did, 148 + recipients, 149 + models.NotificationTypeIssueCommented, 150 + entityType, 151 + entityId, 152 + repoId, 153 + issueId, 154 + pullId, 155 + ) 156 + 157 + case tangled.RepoPullNSID: 158 + pulls, err := db.GetPulls( 159 + n.db, 160 + orm.FilterEq("owner_did", subjectAt.Authority()), 161 + orm.FilterEq("rkey", subjectAt.RecordKey()), 162 + ) 163 + if err != nil { 164 + l.Error("NewComment: failed to get pulls", "err", err) 165 + return 166 + } 167 + if len(pulls) == 0 { 168 + l.Error("NewComment: no pull found", "aturi", comment.Subject) 169 + return 170 + } 171 + pull := pulls[0] 172 + 173 + pull.Repo, err = db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) 174 + if err != nil { 175 + l.Error("NewComment: failed to get repo", "err", err) 176 + return 177 + } 178 + 179 + recipients.Insert(syntax.DID(pull.Repo.Did)) 180 + for _, p := range pull.Participants() { 181 + recipients.Insert(syntax.DID(p)) 182 + } 183 + 184 + entityType = "pull" 185 + entityId = pull.AtUri().String() 186 + repoId = &pull.Repo.Id 187 + p := int64(pull.ID) 188 + pullId = &p 189 + 190 + for _, m := range mentions { 191 + recipients.Remove(m) 192 + } 193 + 194 + n.notifyEvent( 195 + ctx, 196 + comment.Did, 197 + recipients, 198 + models.NotificationTypePullCommented, 199 + entityType, 200 + entityId, 201 + repoId, 202 + issueId, 203 + pullId, 204 + ) 205 + default: 206 + return // no-op 99 207 } 100 - for _, m := range mentions { 101 - recipients.Remove(m) 102 - } 103 - 104 - actorDid := syntax.DID(issue.Did) 105 - entityType := "issue" 106 - entityId := issue.AtUri().String() 107 - repoId := &issue.Repo.Id 108 - issueId := &issue.Id 109 - var pullId *int64 110 208 111 209 n.notifyEvent( 112 210 ctx, 113 - actorDid, 114 - recipients, 115 - models.NotificationTypeIssueCreated, 116 - entityType, 117 - entityId, 118 - repoId, 119 - issueId, 120 - pullId, 121 - ) 122 - n.notifyEvent( 123 - ctx, 124 - actorDid, 211 + comment.Did, 125 212 sets.Collect(slices.Values(mentions)), 126 213 models.NotificationTypeUserMentioned, 127 214 entityType, ··· 132 219 ) 133 220 } 134 221 135 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 222 + func (n *databaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { 223 + // no-op 224 + } 225 + 226 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 136 227 l := log.FromContext(ctx) 137 228 138 - issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) 229 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 139 230 if err != nil { 140 - l.Error("failed to get issues", "err", err) 231 + l.Error("failed to fetch collaborators", "err", err) 141 232 return 142 233 } 143 - if len(issues) == 0 { 144 - l.Error("no issue found for", "err", comment.IssueAt) 145 - return 146 - } 147 - issue := issues[0] 148 234 149 - // built the recipients list: 150 - // - the owner of the repo 151 - // - | if the comment is a reply -> everybody on that thread 152 - // | if the comment is a top level -> just the issue owner 153 - // - remove mentioned users from the recipients list 235 + // build the recipients list 236 + // - owner of the repo 237 + // - collaborators in the repo 238 + // - remove users already mentioned 154 239 recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 155 - 156 - if comment.IsReply() { 157 - // if this comment is a reply, then notify everybody in that thread 158 - parentAtUri := *comment.ReplyTo 159 - 160 - // find the parent thread, and add all DIDs from here to the recipient list 161 - for _, t := range issue.CommentList() { 162 - if t.Self.AtUri().String() == parentAtUri { 163 - for _, p := range t.Participants() { 164 - recipients.Insert(p) 165 - } 166 - } 167 - } 168 - } else { 169 - // not a reply, notify just the issue author 170 - recipients.Insert(syntax.DID(issue.Did)) 240 + for _, c := range collaborators { 241 + recipients.Insert(c.SubjectDid) 171 242 } 172 - 173 243 for _, m := range mentions { 174 244 recipients.Remove(m) 175 245 } 176 246 177 - actorDid := syntax.DID(comment.Did) 247 + actorDid := syntax.DID(issue.Did) 178 248 entityType := "issue" 179 249 entityId := issue.AtUri().String() 180 250 repoId := &issue.Repo.Id ··· 185 255 ctx, 186 256 actorDid, 187 257 recipients, 188 - models.NotificationTypeIssueCommented, 258 + models.NotificationTypeIssueCreated, 189 259 entityType, 190 260 entityId, 191 261 repoId, ··· 273 343 actorDid, 274 344 recipients, 275 345 eventType, 276 - entityType, 277 - entityId, 278 - repoId, 279 - issueId, 280 - pullId, 281 - ) 282 - } 283 - 284 - func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 285 - l := log.FromContext(ctx) 286 - 287 - pull, err := db.GetPull(n.db, 288 - orm.FilterEq("repo_at", syntax.ATURI(comment.RepoAt)), 289 - orm.FilterEq("pull_id", comment.PullId), 290 - ) 291 - if err != nil { 292 - l.Error("failed to get pulls", "err", err) 293 - return 294 - } 295 - 296 - repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) 297 - if err != nil { 298 - l.Error("failed to get repos", "err", err) 299 - return 300 - } 301 - 302 - // build up the recipients list: 303 - // - repo owner 304 - // - all pull participants 305 - // - remove those already mentioned 306 - recipients := sets.Singleton(syntax.DID(repo.Did)) 307 - for _, p := range pull.Participants() { 308 - recipients.Insert(syntax.DID(p)) 309 - } 310 - for _, m := range mentions { 311 - recipients.Remove(m) 312 - } 313 - 314 - actorDid := syntax.DID(comment.OwnerDid) 315 - eventType := models.NotificationTypePullCommented 316 - entityType := "pull" 317 - entityId := pull.AtUri().String() 318 - repoId := &repo.Id 319 - var issueId *int64 320 - p := int64(pull.ID) 321 - pullId := &p 322 - 323 - n.notifyEvent( 324 - ctx, 325 - actorDid, 326 - recipients, 327 - eventType, 328 - entityType, 329 - entityId, 330 - repoId, 331 - issueId, 332 - pullId, 333 - ) 334 - n.notifyEvent( 335 - ctx, 336 - actorDid, 337 - sets.Collect(slices.Values(mentions)), 338 - models.NotificationTypeUserMentioned, 339 346 entityType, 340 347 entityId, 341 348 repoId,
+9 -10
appview/notify/logging/notifier.go
··· 41 41 l.inner.DeleteStar(ctx, star) 42 42 } 43 43 44 + func (l *loggingNotifier) NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 45 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewComment")) 46 + l.inner.NewComment(ctx, comment, mentions) 47 + } 48 + func (l *loggingNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { 49 + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "DeleteComment")) 50 + l.inner.DeleteComment(ctx, comment) 51 + } 52 + 44 53 func (l *loggingNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 45 54 ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewIssue")) 46 55 l.inner.NewIssue(ctx, issue, mentions) 47 - } 48 - 49 - func (l *loggingNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 50 - ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewIssueComment")) 51 - l.inner.NewIssueComment(ctx, comment, mentions) 52 56 } 53 57 54 58 func (l *loggingNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { ··· 84 88 func (l *loggingNotifier) NewPull(ctx context.Context, pull *models.Pull) { 85 89 ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPull")) 86 90 l.inner.NewPull(ctx, pull) 87 - } 88 - 89 - func (l *loggingNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 90 - ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPullComment")) 91 - l.inner.NewPullComment(ctx, comment, mentions) 92 91 } 93 92 94 93 func (l *loggingNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
+8 -8
appview/notify/merged_notifier.go
··· 46 46 m.fanout(func(n Notifier) { n.DeleteStar(ctx, star) }) 47 47 } 48 48 49 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 50 - m.fanout(func(n Notifier) { n.NewIssue(ctx, issue, mentions) }) 49 + func (m *mergedNotifier) NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 50 + m.fanout(func(n Notifier) { n.NewComment(ctx, comment, mentions) }) 51 51 } 52 52 53 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 54 - m.fanout(func(n Notifier) { n.NewIssueComment(ctx, comment, mentions) }) 53 + func (m *mergedNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { 54 + m.fanout(func(n Notifier) { n.DeleteComment(ctx, comment) }) 55 + } 56 + 57 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 58 + m.fanout(func(n Notifier) { n.NewIssue(ctx, issue, mentions) }) 55 59 } 56 60 57 61 func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { ··· 80 84 81 85 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 82 86 m.fanout(func(n Notifier) { n.NewPull(ctx, pull) }) 83 - } 84 - 85 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 86 - m.fanout(func(n Notifier) { n.NewPullComment(ctx, comment, mentions) }) 87 87 } 88 88 89 89 func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
+8 -7
appview/notify/notifier.go
··· 14 14 NewStar(ctx context.Context, star *models.Star) 15 15 DeleteStar(ctx context.Context, star *models.Star) 16 16 17 + NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) 18 + DeleteComment(ctx context.Context, comment *models.Comment) 19 + 17 20 NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 18 - NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 19 21 NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 20 22 DeleteIssue(ctx context.Context, issue *models.Issue) 21 23 ··· 23 25 DeleteFollow(ctx context.Context, follow *models.Follow) 24 26 25 27 NewPull(ctx context.Context, pull *models.Pull) 26 - NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 27 28 NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 28 29 29 30 NewIssueLabelOp(ctx context.Context, issue *models.Issue) ··· 51 52 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 52 53 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 53 54 54 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 55 - func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 55 + func (m *BaseNotifier) NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 56 56 } 57 + func (m *BaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {} 58 + 59 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 57 60 func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 58 61 func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} 59 62 ··· 63 66 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 64 67 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 65 68 66 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 67 - func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { 68 - } 69 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 69 70 func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 70 71 71 72 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+5 -20
appview/notify/posthog/notifier.go
··· 86 86 } 87 87 } 88 88 89 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 90 - err := n.client.Enqueue(posthog.Capture{ 91 - DistinctId: comment.OwnerDid, 92 - Event: "new_pull_comment", 93 - Properties: posthog.Properties{ 94 - "repo_at": comment.RepoAt, 95 - "pull_id": comment.PullId, 96 - "mentions": mentions, 97 - }, 98 - }) 99 - if err != nil { 100 - log.Println("failed to enqueue posthog event:", err) 101 - } 102 - } 103 - 104 89 func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 105 90 err := n.client.Enqueue(posthog.Capture{ 106 91 DistinctId: pull.OwnerDid, ··· 191 176 } 192 177 } 193 178 194 - func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 179 + func (n *posthogNotifier) NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 195 180 err := n.client.Enqueue(posthog.Capture{ 196 - DistinctId: comment.Did, 197 - Event: "new_issue_comment", 181 + DistinctId: comment.Did.String(), 182 + Event: "new_comment", 198 183 Properties: posthog.Properties{ 199 - "issue_at": comment.IssueAt, 200 - "mentions": mentions, 184 + "subject_at": comment.Subject.Uri, 185 + "mentions": mentions, 201 186 }, 202 187 }) 203 188 if err != nil {
+13 -1
appview/oauth/handler.go
··· 96 96 go o.addToDefaultSpindle(sessData.AccountDID.String()) 97 97 go o.ensureTangledProfile(sessData) 98 98 go o.autoClaimTnglShDomain(sessData.AccountDID.String()) 99 - go o.drainPdsRewrites(sessData) 100 99 101 100 if !o.Config.Core.Dev { 102 101 err = o.Posthog.Enqueue(posthog.Capture{ ··· 273 272 } 274 273 275 274 l.Debug("successfully created empty Tangled profile on PDS and DB") 275 + } 276 + 277 + func (o *OAuth) PdsRewriteMiddleware(next http.Handler) http.Handler { 278 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 279 + defer next.ServeHTTP(w, r) 280 + 281 + sess, err := o.ResumeSession(r) 282 + if err != nil { 283 + return 284 + } 285 + 286 + go o.drainPdsRewrites(sess.Data) 287 + }) 276 288 } 277 289 278 290 func (o *OAuth) drainPdsRewrites(sessData *oauth.ClientSessionData) {
+1
appview/oauth/scopes.go
··· 16 16 "repo:sh.tangled.spindle", 17 17 "repo:sh.tangled.spindle.member", 18 18 "repo:sh.tangled.graph.follow", 19 + "repo:sh.tangled.feed.comment", 19 20 "repo:sh.tangled.feed.star", 20 21 "repo:sh.tangled.feed.reaction", 21 22 "repo:sh.tangled.label.definition",
+17
appview/pages/funcmap.go
··· 23 23 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 24 24 "github.com/alecthomas/chroma/v2/lexers" 25 25 "github.com/alecthomas/chroma/v2/styles" 26 + "github.com/bluesky-social/indigo/atproto/syntax" 26 27 "github.com/dustin/go-humanize" 27 28 "github.com/go-enry/go-enry/v2" 28 29 "github.com/yuin/goldmark" ··· 479 480 }, 480 481 "isGenerated": func(path string) bool { 481 482 return enry.IsGenerated(path, nil) 483 + }, 484 + // NOTE(boltless): I know... I hate doing this too 485 + "asReactionMapMap": func(dict any) map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData { 486 + if dict == nil { 487 + return make(map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData) 488 + } 489 + m, _ := dict.(map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData) 490 + return m 491 + }, 492 + "asReactionStatusMapMap": func(dict any) map[syntax.ATURI]map[models.ReactionKind]bool { 493 + if dict == nil { 494 + log.Println("returning empty map") 495 + return make(map[syntax.ATURI]map[models.ReactionKind]bool) 496 + } 497 + m, _ := dict.(map[syntax.ATURI]map[models.ReactionKind]bool) 498 + return m 482 499 }, 483 500 // constant values used to define a template 484 501 "const": func() map[string]any {
+43 -10
appview/pages/pages.go
··· 1126 1126 Backlinks []models.RichReferenceLink 1127 1127 LabelDefs map[string]*models.LabelDefinition 1128 1128 1129 - Reactions map[models.ReactionKind]models.ReactionDisplayData 1130 - UserReacted map[models.ReactionKind]bool 1129 + Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData 1130 + UserReacted map[syntax.ATURI]map[models.ReactionKind]bool 1131 1131 } 1132 1132 1133 1133 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { ··· 1148 1148 } 1149 1149 1150 1150 type ThreadReactionFragmentParams struct { 1151 - ThreadAt syntax.ATURI 1152 1151 Kind models.ReactionKind 1153 1152 Count int 1154 1153 Users []string ··· 1177 1176 LoggedInUser *oauth.MultiAccountUser 1178 1177 RepoInfo repoinfo.RepoInfo 1179 1178 Issue *models.Issue 1180 - Comment *models.IssueComment 1179 + Comment *models.Comment 1181 1180 } 1182 1181 1183 1182 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 1188 1187 LoggedInUser *oauth.MultiAccountUser 1189 1188 RepoInfo repoinfo.RepoInfo 1190 1189 Issue *models.Issue 1191 - Comment *models.IssueComment 1190 + Comment *models.Comment 1192 1191 } 1193 1192 1194 1193 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1199 1198 LoggedInUser *oauth.MultiAccountUser 1200 1199 RepoInfo repoinfo.RepoInfo 1201 1200 Issue *models.Issue 1202 - Comment *models.IssueComment 1201 + Comment *models.Comment 1203 1202 } 1204 1203 1205 1204 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1209 1208 type IssueCommentBodyParams struct { 1210 1209 LoggedInUser *oauth.MultiAccountUser 1211 1210 RepoInfo repoinfo.RepoInfo 1212 - Issue *models.Issue 1213 - Comment *models.IssueComment 1211 + Comment *models.Comment 1214 1212 } 1215 1213 1216 1214 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { ··· 1287 1285 ActiveRound int 1288 1286 IsInterdiff bool 1289 1287 1290 - Reactions map[models.ReactionKind]models.ReactionDisplayData 1291 - UserReacted map[models.ReactionKind]bool 1288 + Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData 1289 + UserReacted map[syntax.ATURI]map[models.ReactionKind]bool 1292 1290 1293 1291 LabelDefs map[string]*models.LabelDefinition 1294 1292 } ··· 1583 1581 IsStarred bool 1584 1582 StarCount int 1585 1583 Owner identity.Identity 1584 + CommentList []models.CommentListItem 1585 + Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData 1586 + UserReacted map[syntax.ATURI]map[models.ReactionKind]bool 1586 1587 } 1587 1588 1588 1589 func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { ··· 1606 1607 1607 1608 func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1608 1609 return p.execute("timeline/home", w, params) 1610 + } 1611 + 1612 + type CommentBodyFragmentParams struct { 1613 + Comment models.Comment 1614 + } 1615 + 1616 + func (p *Pages) CommentBodyFragment(w io.Writer, params CommentBodyFragmentParams) error { 1617 + return p.executePlain("fragments/comment/commentBody", w, params) 1618 + } 1619 + 1620 + type EditCommentFragmentParams struct { 1621 + Comment models.Comment 1622 + } 1623 + 1624 + func (p *Pages) EditCommentFragment(w io.Writer, params EditCommentFragmentParams) error { 1625 + return p.executePlain("fragments/comment/edit", w, params) 1626 + } 1627 + 1628 + type ReplyCommentFragmentParams struct { 1629 + LoggedInUser *oauth.MultiAccountUser 1630 + } 1631 + 1632 + func (p *Pages) ReplyCommentFragment(w io.Writer, params ReplyCommentFragmentParams) error { 1633 + return p.executePlain("fragments/comment/reply", w, params) 1634 + } 1635 + 1636 + type ReplyPlaceholderFragmentParams struct { 1637 + LoggedInUser *oauth.MultiAccountUser 1638 + } 1639 + 1640 + func (p *Pages) ReplyPlaceholderFragment(w io.Writer, params ReplyPlaceholderFragmentParams) error { 1641 + return p.executePlain("fragments/comment/replyPlaceholder", w, params) 1609 1642 } 1610 1643 1611 1644 func (p *Pages) Static() http.Handler {
+13
appview/pages/templates/fragments/comment/commentBody.html
··· 1 + {{ define "fragments/comment/commentBody" }} 2 + <div class="comment-body"> 3 + {{ if not .Comment.Deleted }} 4 + <div class="prose dark:prose-invert">{{ .Comment.Body.Text | markdown }}</div> 5 + {{ template "repo/fragments/reactions" 6 + (dict "Reactions" .Reactions 7 + "UserReacted" .UserReacted 8 + "ThreadAt" .Comment.AtUri) }} 9 + {{ else }} 10 + <div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div> 11 + {{ end }} 12 + </div> 13 + {{ end }}
+61
appview/pages/templates/fragments/comment/commentHeader.html
··· 1 + {{ define "fragments/comment/commentHeader" }} 2 + <div 3 + class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 " 4 + hx-target="next .comment-body" 5 + > 6 + {{ $handle := resolve .Comment.Did.String }} 7 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="/{{ $handle }}">{{ $handle }}</a> 8 + {{ template "hats" $ }} 9 + <span class="before:content-['ยท']"></span> 10 + {{ template "timestamp" . }} 11 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} 12 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 13 + {{ if not .Comment.IsLegacy }} 14 + {{ template "editCommentBtn" . }} 15 + {{ end }} 16 + {{ template "deleteCommentBtn" . }} 17 + {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "hats" }} 22 + {{ $isIssueAuthor := eq .Comment.Did .Issue.Did }} 23 + {{ if $isIssueAuthor }} 24 + (author) 25 + {{ end }} 26 + {{ end }} 27 + 28 + {{ define "timestamp" }} 29 + <a href="#comment-{{ .Comment.Id }}" 30 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 31 + id="comment-{{ .Comment.Id }}"> 32 + {{ if .Comment.Deleted }} 33 + {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 34 + {{ else if .Comment.Edited }} 35 + edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }} 36 + {{ else }} 37 + {{ template "repo/fragments/shortTimeAgo" .Comment.Created }} 38 + {{ end }} 39 + </a> 40 + {{ end }} 41 + 42 + {{ define "editCommentBtn" }} 43 + <a 44 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 45 + hx-get="/comment/edit?aturi={{ .Comment.AtUri }}" 46 + > 47 + {{ i "pencil" "size-3 inline group-[.htmx-request]:hidden" }} 48 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 49 + </a> 50 + {{ end }} 51 + 52 + {{ define "deleteCommentBtn" }} 53 + <a 54 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 55 + hx-delete="/comment?aturi={{ .Comment.AtUri }}" 56 + hx-confirm="Are you sure you want to delete your comment?" 57 + > 58 + {{ i "trash-2" "size-3 inline group-[.htmx-request]:hidden" }} 59 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 60 + </a> 61 + {{ end }}
+76
appview/pages/templates/fragments/comment/commentList.html
··· 1 + {{ define "fragments/comment/commentList" }} 2 + <div class="flex flex-col gap-4"> 3 + {{ range $item := .CommentList }} 4 + {{ template "commentListItem" (list $ .) }} 5 + {{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "commentListItem" }} 10 + {{ $root := index . 0 }} 11 + {{ $item := index . 1 }} 12 + 13 + <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> 14 + {{ 15 + template "topLevelComment" 16 + (dict 17 + "LoggedInUser" $root.LoggedInUser 18 + "Reactions" (index (asReactionMapMap $root.Reactions) $item.Self.AtUri) 19 + "UserReacted" (index (asReactionStatusMapMap $root.UserReacted) $item.Self.AtUri) 20 + "Comment" $item.Self) 21 + }} 22 + 23 + <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 24 + {{ range $index, $reply := $item.Replies }} 25 + <div class="-ml-4"> 26 + {{ 27 + template "replyComment" 28 + (dict 29 + "LoggedInUser" $root.LoggedInUser 30 + "Reactions" (index (asReactionMapMap $root.Reactions) $reply.AtUri) 31 + "UserReacted" (index (asReactionStatusMapMap $root.UserReacted) $reply.AtUri) 32 + "Comment" $reply) 33 + }} 34 + </div> 35 + {{ end }} 36 + </div> 37 + 38 + <div hx-include="this"> 39 + <input name="subject-uri" type="hidden" value="{{ $item.Self.Subject.Uri }}"> 40 + <input name="subject-cid" type="hidden" value="{{ $item.Self.Subject.Cid }}"> 41 + <input name="reply-to-uri" type="hidden" value="{{ $item.Self.AtUri }}"> 42 + <input name="reply-to-cid" type="hidden" value="{{ $item.Self.Cid }}"> 43 + {{ if $item.Self.IsLegacy }} 44 + <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 45 + <span class="text-orange-500">Can't reply to legacy comment.</span> 46 + </div> 47 + {{ else }} 48 + {{ template "fragments/comment/replyPlaceholder" (dict "LoggedInUser" $root.LoggedInUser) }} 49 + {{ end }} 50 + </div> 51 + </div> 52 + {{ end }} 53 + 54 + {{ define "topLevelComment" }} 55 + <div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2 "> 56 + <div class="flex-shrink-0"> 57 + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }} 58 + </div> 59 + <div class="flex-1 min-w-0"> 60 + {{ template "fragments/comment/commentHeader" . }} 61 + {{ template "fragments/comment/commentBody" . }} 62 + </div> 63 + </div> 64 + {{ end }} 65 + 66 + {{ define "replyComment" }} 67 + <div class="py-4 pr-4 w-full mx-auto flex gap-2 "> 68 + <div class="flex-shrink-0"> 69 + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }} 70 + </div> 71 + <div class="flex-1 min-w-0"> 72 + {{ template "fragments/comment/commentHeader" . }} 73 + {{ template "fragments/comment/commentBody" . }} 74 + </div> 75 + </div> 76 + {{ end }}
+49
appview/pages/templates/fragments/comment/edit.html
··· 1 + {{ define "fragments/comment/edit" }} 2 + <form 3 + class="pt-2" 4 + hx-patch="/comment" 5 + hx-swap="outerHTML" 6 + hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:find textarea" 7 + hx-indicator="find button[type='submit']" 8 + hx-disabled-elt="find button[type='submit']" 9 + > 10 + <input name="aturi" type="hidden" value="{{ .Comment.AtUri }}"> 11 + <textarea 12 + name="body" 13 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 14 + rows="5" 15 + autofocus>{{ .Comment.EditableBody }}</textarea> 16 + <div id="comment-error" class="error"></div> 17 + {{ template "editActions" $ }} 18 + </form> 19 + {{ end }} 20 + 21 + {{ define "editActions" }} 22 + <div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2"> 23 + {{ template "cancel" . }} 24 + {{ template "save" . }} 25 + </div> 26 + {{ end }} 27 + 28 + {{ define "save" }} 29 + <button 30 + type="submit" 31 + class="btn-create py-0 flex gap-1 items-center group text-sm" 32 + > 33 + {{ i "check" "size-4" }} 34 + save 35 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 + </button> 37 + {{ end }} 38 + 39 + {{ define "cancel" }} 40 + <button 41 + class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group" 42 + hx-get="/comment?aturi={{ .Comment.AtUri }}" 43 + hx-target="closest form" 44 + hx-swap="outerHTML" 45 + > 46 + {{ i "x" "size-4" }} 47 + cancel 48 + </button> 49 + {{ end }}
+50
appview/pages/templates/fragments/comment/reply.html
··· 1 + {{ define "fragments/comment/reply" }} 2 + <form 3 + class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2" 4 + hx-post="/comment" 5 + hx-swap="none" 6 + hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:find textarea" 7 + hx-on::after-request="if(event.detail.successful) this.reset()" 8 + hx-disabled-elt="find button[type='submit']" 9 + > 10 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 11 + <textarea 12 + name="body" 13 + class="w-full p-2" 14 + placeholder="Leave a reply..." 15 + autofocus 16 + rows="3"></textarea> 17 + <div id="comment-error" class="error"></div> 18 + {{ template "replyActions" . }} 19 + </form> 20 + {{ end }} 21 + 22 + {{ define "replyActions" }} 23 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm"> 24 + {{ template "cancel" . }} 25 + {{ template "reply" . }} 26 + </div> 27 + {{ end }} 28 + 29 + {{ define "cancel" }} 30 + <button 31 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 32 + hx-get="/comment/reply/placeholder" 33 + hx-target="closest form" 34 + hx-swap="outerHTML" 35 + > 36 + {{ i "x" "size-4" }} 37 + cancel 38 + </button> 39 + {{ end }} 40 + 41 + {{ define "reply" }} 42 + <button 43 + type="submit" 44 + class="btn-create flex items-center gap-2 no-underline hover:no-underline" 45 + > 46 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 47 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 48 + reply 49 + </button> 50 + {{ end }}
+15
appview/pages/templates/fragments/comment/replyPlaceholder.html
··· 1 + {{ define "fragments/comment/replyPlaceholder" }} 2 + <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 + {{ if .LoggedInUser }} 4 + {{ template "user/fragments/pic" (list .LoggedInUser.Did "size-8 mr-1") }} 5 + {{ end }} 6 + <input 7 + class="w-full p-0 border-none focus:outline-none bg-transparent" 8 + placeholder="Leave a reply..." 9 + hx-get="/comment/reply" 10 + hx-trigger="focus" 11 + hx-target="closest div" 12 + hx-swap="outerHTML" 13 + > 14 + </div> 15 + {{ end }}
+3 -4
appview/pages/templates/repo/fragments/reaction.html
··· 1 1 {{ define "repo/fragments/reaction" }} 2 2 <button 3 - id="reactIndi-{{ .Kind }}" 4 3 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 4 leading-4 px-3 gap-1 relative group 6 5 {{ if eq .Count 0 }} ··· 26 25 title="{{ .Kind }}" 27 26 {{ end }} 28 27 {{ if .IsReacted }} 29 - hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 28 + hx-delete="/react?kind={{ .Kind }}" 30 29 {{ else }} 31 - hx-post="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 30 + hx-post="/react?kind={{ .Kind }}" 32 31 {{ end }} 33 32 hx-swap="outerHTML" 34 - hx-trigger="click from:(#reactBtn-{{ .Kind }}, #reactIndi-{{ .Kind }})" 33 + hx-trigger="click from:(previous #reactBtn-{{ .Kind }}, closest button)" 35 34 hx-disabled-elt="this" 36 35 > 37 36 <span>{{ .Kind }}</span> <span>{{ .Count }}</span>
+6 -7
appview/pages/templates/repo/fragments/reactions.html
··· 1 1 {{ define "repo/fragments/reactions" }} 2 - <div class="flex flex-wrap items-center gap-2"> 2 + <div class="reactions flex flex-wrap items-center gap-2" hx-include="this"> 3 3 {{- $reactions := .Reactions -}} 4 4 {{- $userReacted := .UserReacted -}} 5 5 {{- $threadAt := .ThreadAt -}} 6 + 7 + <input name="subject-uri" type="hidden" value="{{ $threadAt }}"> 6 8 7 9 {{ template "reactionsPopup" }} 10 + 8 11 {{ range $kind := const.OrderedReactionKinds }} 9 12 {{ $reactionData := index $reactions $kind }} 10 13 {{ template "repo/fragments/reaction" ··· 12 15 "Kind" $kind 13 16 "Count" $reactionData.Count 14 17 "IsReacted" (index $userReacted $kind) 15 - "ThreadAt" $threadAt 16 18 "Users" $reactionData.Users) }} 17 19 {{ end }} 18 20 </div> 19 21 {{ end }} 20 22 21 23 {{ define "reactionsPopup" }} 22 - <details 23 - id="reactionsPopUp" 24 - class="relative inline-block" 25 - > 24 + <details class="relative inline-block"> 26 25 <summary 27 26 class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700 28 27 hover:bg-gray-50 ··· 44 43 > 45 44 {{ $kind }} 46 45 </button> 47 - {{ end }} 46 + {{ end }} 48 47 </div> 49 48 </details> 50 49 {{ end }}
-63
appview/pages/templates/repo/issues/fragments/commentList.html
··· 1 - {{ define "repo/issues/fragments/commentList" }} 2 - <div class="flex flex-col gap-4"> 3 - {{ range $item := .CommentList }} 4 - {{ template "commentListing" (list $ .) }} 5 - {{ end }} 6 - </div> 7 - {{ end }} 8 - 9 - {{ define "commentListing" }} 10 - {{ $root := index . 0 }} 11 - {{ $comment := index . 1 }} 12 - {{ $params := 13 - (dict 14 - "RepoInfo" $root.RepoInfo 15 - "LoggedInUser" $root.LoggedInUser 16 - "Issue" $root.Issue 17 - "Comment" $comment.Self) }} 18 - 19 - <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> 20 - {{ template "topLevelComment" $params }} 21 - 22 - <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 23 - {{ range $index, $reply := $comment.Replies }} 24 - <div class="-ml-4"> 25 - {{ 26 - template "replyComment" 27 - (dict 28 - "RepoInfo" $root.RepoInfo 29 - "LoggedInUser" $root.LoggedInUser 30 - "Issue" $root.Issue 31 - "Comment" $reply) 32 - }} 33 - </div> 34 - {{ end }} 35 - </div> 36 - 37 - {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} 38 - </div> 39 - {{ end }} 40 - 41 - {{ define "topLevelComment" }} 42 - <div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2 "> 43 - <div class="flex-shrink-0"> 44 - {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1") }} 45 - </div> 46 - <div class="flex-1 min-w-0"> 47 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 48 - {{ template "repo/issues/fragments/issueCommentBody" . }} 49 - </div> 50 - </div> 51 - {{ end }} 52 - 53 - {{ define "replyComment" }} 54 - <div class="py-4 pr-4 w-full mx-auto overflow-hidden flex gap-2 "> 55 - <div class="flex-shrink-0"> 56 - {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1") }} 57 - </div> 58 - <div class="flex-1 min-w-0"> 59 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 60 - {{ template "repo/issues/fragments/issueCommentBody" . }} 61 - </div> 62 - </div> 63 - {{ end }}
-44
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 - {{ define "repo/issues/fragments/editIssueComment" }} 2 - <div id="comment-body-{{.Comment.Id}}" class="pt-2"> 3 - <textarea 4 - id="edit-textarea-{{ .Comment.Id }}" 5 - name="body" 6 - class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 7 - rows="5" 8 - autofocus>{{ .Comment.Body }}</textarea> 9 - 10 - {{ template "editActions" $ }} 11 - </div> 12 - {{ end }} 13 - 14 - {{ define "editActions" }} 15 - <div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2"> 16 - {{ template "cancel" . }} 17 - {{ template "save" . }} 18 - </div> 19 - {{ end }} 20 - 21 - {{ define "save" }} 22 - <button 23 - class="btn-create py-0 flex gap-1 items-center group text-sm" 24 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 25 - hx-trigger="click, keydown[(ctrlKey || metaKey) && key=='Enter'] from:#edit-textarea-{{ .Comment.Id }}" 26 - hx-include="#edit-textarea-{{ .Comment.Id }}" 27 - hx-target="#comment-body-{{ .Comment.Id }}" 28 - hx-swap="outerHTML"> 29 - {{ i "check" "size-4" }} 30 - save 31 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 - </button> 33 - {{ end }} 34 - 35 - {{ define "cancel" }} 36 - <button 37 - class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group" 38 - hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 39 - hx-target="#comment-body-{{ .Comment.Id }}" 40 - hx-swap="outerHTML"> 41 - {{ i "x" "size-4" }} 42 - cancel 43 - </button> 44 - {{ end }}
-9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
··· 1 - {{ define "repo/issues/fragments/issueCommentBody" }} 2 - <div id="comment-body-{{.Comment.Id}}"> 3 - {{ if not .Comment.Deleted }} 4 - <div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div> 5 - {{ else }} 6 - <div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div> 7 - {{ end }} 8 - </div> 9 - {{ end }}
-59
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 - {{ define "repo/issues/fragments/issueCommentHeader" }} 2 - <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 - {{ $handle := resolve .Comment.Did }} 4 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="/{{ $handle }}">{{ $handle }}</a> 5 - {{ template "hats" $ }} 6 - <span class="before:content-['ยท']"></span> 7 - {{ template "timestamp" . }} 8 - {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 9 - {{ if and $isCommentOwner (not .Comment.Deleted) }} 10 - {{ template "editIssueComment" . }} 11 - {{ template "deleteIssueComment" . }} 12 - {{ end }} 13 - </div> 14 - {{ end }} 15 - 16 - {{ define "hats" }} 17 - {{ $isIssueAuthor := eq .Comment.Did .Issue.Did }} 18 - {{ if $isIssueAuthor }} 19 - (author) 20 - {{ end }} 21 - {{ end }} 22 - 23 - {{ define "timestamp" }} 24 - <a href="#comment-{{ .Comment.Id }}" 25 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 26 - id="comment-{{ .Comment.Id }}"> 27 - {{ if .Comment.Deleted }} 28 - {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 29 - {{ else if .Comment.Edited }} 30 - edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }} 31 - {{ else }} 32 - {{ template "repo/fragments/shortTimeAgo" .Comment.Created }} 33 - {{ end }} 34 - </a> 35 - {{ end }} 36 - 37 - {{ define "editIssueComment" }} 38 - <a 39 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 40 - hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 41 - hx-swap="outerHTML" 42 - hx-target="#comment-body-{{.Comment.Id}}"> 43 - {{ i "pencil" "size-3 inline group-[.htmx-request]:hidden" }} 44 - {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 45 - </a> 46 - {{ end }} 47 - 48 - {{ define "deleteIssueComment" }} 49 - <a 50 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 51 - hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 52 - hx-confirm="Are you sure you want to delete your comment?" 53 - hx-swap="outerHTML" 54 - hx-target="#comment-body-{{.Comment.Id}}" 55 - > 56 - {{ i "trash-2" "size-3 inline group-[.htmx-request]:hidden" }} 57 - {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 58 - </a> 59 - {{ end }}
+16 -37
appview/pages/templates/repo/issues/fragments/newComment.html
··· 1 1 {{ define "repo/issues/fragments/newComment" }} 2 2 {{ if .LoggedInUser }} 3 3 <form 4 - id="comment-form" 5 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 - hx-trigger="submit, keydown[commentButtonEnabled() && (ctrlKey || metaKey) && key=='Enter'] from:#comment-textarea" 7 - hx-disabled-elt="#comment-form button" 8 - hx-on::after-request="if(event.detail.successful) this.reset()" 9 - class="group/form" 4 + hx-post="/comment" 5 + hx-trigger="submit, click from:#close-button, keydown[commentButtonEnabled() && (ctrlKey || metaKey) && key=='Enter'] from:#comment-textarea" 6 + hx-disabled-elt="find button[type='submit']" 7 + hx-on::after-request="if(event.detail.successful) this.reset()" 8 + class="group/form" 10 9 > 10 + <input name="subject-uri" type="hidden" value="{{ .Issue.AtUri }}"> 11 11 <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> 12 12 <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 13 13 {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 14 14 </div> 15 - <textarea 16 - id="comment-textarea" 17 - name="body" 18 - class="w-full p-2 rounded" 19 - placeholder="Add to the discussion. Markdown is supported." 20 - onkeyup="updateCommentForm()" 21 - rows="5" 22 - ></textarea> 23 - <div id="issue-comment"></div> 15 + <textarea 16 + id="comment-textarea" 17 + name="body" 18 + class="w-full p-2 rounded" 19 + placeholder="Add to the discussion. Markdown is supported." 20 + onkeyup="updateCommentForm()" 21 + rows="5" 22 + required 23 + ></textarea> 24 + <div id="comment-error" class="error"></div> 24 25 <div id="issue-action" class="error"></div> 25 26 </div> 26 27 ··· 51 52 <span id="close-button-text">close</span> 52 53 </button> 53 54 <div 54 - id="close-with-comment" 55 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 56 - hx-trigger="click from:#close-button" 57 - hx-disabled-elt="#close-with-comment" 58 - hx-target="#issue-comment" 59 - hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 60 - hx-swap="none" 61 - hx-indicator="#close-button" 62 - > 63 - </div> 64 - <div 65 55 id="close-issue" 66 56 hx-disabled-elt="#close-issue" 67 57 hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" ··· 71 61 hx-indicator="#close-button" 72 62 > 73 63 </div> 74 - <script> 75 - document.addEventListener('htmx:configRequest', function(evt) { 76 - if (evt.target.id === 'close-with-comment') { 77 - const commentText = document.getElementById('comment-textarea').value.trim(); 78 - if (commentText === '') { 79 - evt.detail.parameters = {}; 80 - evt.preventDefault(); 81 - } 82 - } 83 - }); 84 - </script> 85 64 {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }} 86 65 <button 87 66 type="button"
-58
appview/pages/templates/repo/issues/fragments/replyComment.html
··· 1 - {{ define "repo/issues/fragments/replyComment" }} 2 - <form 3 - class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2" 4 - id="reply-form-{{ .Comment.Id }}" 5 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 - hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:#reply-{{.Comment.Id}}-textarea" 7 - hx-on::after-request="if(event.detail.successful) this.reset()" 8 - hx-disabled-elt="#reply-{{ .Comment.Id }}" 9 - > 10 - {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 11 - <textarea 12 - id="reply-{{.Comment.Id}}-textarea" 13 - name="body" 14 - class="w-full p-2" 15 - placeholder="Leave a reply..." 16 - autofocus 17 - rows="3"></textarea> 18 - 19 - <input 20 - type="text" 21 - id="reply-to" 22 - name="reply-to" 23 - required 24 - value="{{ .Comment.AtUri }}" 25 - class="hidden" 26 - /> 27 - {{ template "replyActions" . }} 28 - </form> 29 - {{ end }} 30 - 31 - {{ define "replyActions" }} 32 - <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm"> 33 - {{ template "cancel" . }} 34 - {{ template "reply" . }} 35 - </div> 36 - {{ end }} 37 - 38 - {{ define "cancel" }} 39 - <button 40 - class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 41 - hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder" 42 - hx-target="#reply-form-{{ .Comment.Id }}" 43 - hx-swap="outerHTML"> 44 - {{ i "x" "size-4" }} 45 - cancel 46 - </button> 47 - {{ end }} 48 - 49 - {{ define "reply" }} 50 - <button 51 - id="reply-{{ .Comment.Id }}" 52 - type="submit" 53 - class="btn-create flex items-center gap-2 no-underline hover:no-underline"> 54 - {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 55 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 56 - reply 57 - </button> 58 - {{ end }}
-16
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 - {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 - <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 - {{ if .LoggedInUser }} 4 - {{ template "user/fragments/pic" (list .LoggedInUser.Did "size-8 mr-1") }} 5 - {{ end }} 6 - <input 7 - class="w-full p-0 border-none focus:outline-none bg-transparent" 8 - placeholder="Leave a reply..." 9 - hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 10 - hx-trigger="focus" 11 - hx-target="closest div" 12 - hx-swap="outerHTML" 13 - > 14 - </input> 15 - </div> 16 - {{ end }}
+8 -7
appview/pages/templates/repo/issues/issue.html
··· 36 36 <article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article> 37 37 {{ end }} 38 38 <div class="mt-4"> 39 + {{ $aturi := .Issue.AtUri }} 39 40 {{ template "repo/fragments/reactions" 40 - (dict "Reactions" .Reactions 41 - "UserReacted" .UserReacted 42 - "ThreadAt" .Issue.AtUri) }} 41 + (dict "Reactions" (index .Reactions $aturi) 42 + "UserReacted" (index .UserReacted $aturi) 43 + "ThreadAt" $aturi) }} 43 44 </div> 44 45 </section> 45 46 {{ end }} ··· 113 114 {{ define "repoAfter" }} 114 115 <div class="flex flex-col gap-4 mt-4"> 115 116 {{ 116 - template "repo/issues/fragments/commentList" 117 + template "fragments/comment/commentList" 117 118 (dict 118 - "RepoInfo" $.RepoInfo 119 119 "LoggedInUser" $.LoggedInUser 120 - "Issue" $.Issue 121 - "CommentList" $.Issue.CommentList) 120 + "CommentList" $.CommentList 121 + "Reactions" $.Reactions 122 + "UserReacted" $.UserReacted) 122 123 }} 123 124 124 125 {{ template "repo/issues/fragments/newComment" . }}
+8 -18
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 22 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 - <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative p-2"> 25 + <div id="actions-{{$roundNumber}}" hx-target="this" class="flex flex-wrap gap-2 relative p-2"> 26 26 <button 27 27 hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 28 - hx-target="#actions-{{$roundNumber}}" 29 - hx-swap="outerHtml" 30 28 class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 29 {{ i "message-square-plus" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 32 30 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} ··· 44 42 </button> 45 43 {{ end }} 46 44 {{ if and $isPushAllowed $isOpen $isLastRound }} 47 - {{ $disabled := "" }} 48 - {{ if $isConflicted }} 49 - {{ $disabled = "disabled" }} 50 - {{ end }} 51 45 <button 52 46 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 53 47 hx-swap="none" 54 48 hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 55 - class="btn-flat p-2 flex items-center gap-2 group" {{ $disabled }}> 49 + class="btn-flat p-2 flex items-center gap-2 group" 50 + {{ if $isConflicted }}disabled{{ end }} 51 + > 56 52 {{ i "git-merge" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 57 53 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 54 merge{{if $stackCount}} {{$stackCount}}{{end}} ··· 60 56 {{ end }} 61 57 62 58 {{ if and $isPullAuthor $isOpen $isLastRound }} 63 - {{ $disabled := "" }} 64 - {{ if $isUpToDate }} 65 - {{ $disabled = "disabled" }} 66 - {{ end }} 67 59 <button id="resubmitBtn" 68 60 {{ if not .Pull.IsPatchBased }} 69 61 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 62 + hx-swap="none" 70 63 {{ else }} 71 64 hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 72 - hx-target="#actions-{{$roundNumber}}" 73 - hx-swap="outerHtml" 74 65 {{ end }} 75 66 76 67 hx-disabled-elt="#resubmitBtn" 77 - class="btn-flat p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 68 + class="btn-flat p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" 78 69 79 - {{ if $disabled }} 70 + {{ if not $isUpToDate }} 80 71 title="Update this branch to resubmit this pull request" 72 + disabled 81 73 {{ else }} 82 74 title="Resubmit this pull request" 83 75 {{ end }} ··· 111 103 {{ end }} 112 104 </div> 113 105 {{ end }} 114 - 115 -
+4 -3
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 48 48 {{ end }} 49 49 50 50 <div class="mt-2"> 51 + {{ $aturi := .Pull.AtUri }} 51 52 {{ template "repo/fragments/reactions" 52 - (dict "Reactions" .Reactions 53 - "UserReacted" .UserReacted 54 - "ThreadAt" .Pull.AtUri) }} 53 + (dict "Reactions" (index .Reactions $aturi) 54 + "UserReacted" (index .UserReacted $aturi) 55 + "ThreadAt" $aturi) }} 55 56 </div> 56 57 </section> 57 58
+12 -14
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 1 1 {{ define "repo/pulls/fragments/pullNewComment" }} 2 - <div 3 - id="pull-comment-card-{{ .RoundNumber }}" 4 - class="w-full flex flex-col gap-2"> 2 + <div class="w-full flex flex-col gap-2"> 5 3 {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 6 4 <form 7 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 8 - hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:#pull-comment-textarea" 5 + class="w-full flex flex-wrap gap-2 group" 6 + hx-post="/comment" 7 + hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:find textarea" 9 8 hx-swap="none" 9 + hx-indicator="find button[type='submit']" 10 + hx-disabled-elt="find button[type='submit']" 10 11 hx-on::after-request="if(event.detail.successful) this.reset()" 11 - hx-disabled-elt="#reply-{{ .RoundNumber }}" 12 - class="w-full flex flex-wrap gap-2 group" 13 12 > 13 + <input name="subject-uri" type="hidden" value="{{ .Pull.AtUri }}"> 14 + <input name="pull-round-idx" type="hidden" value="{{ .RoundNumber }}"> 14 15 <textarea 15 - id="pull-comment-textarea" 16 - name="body" 17 - class="w-full p-2 rounded border" 18 - rows=8 19 - placeholder="Add to the discussion..."></textarea 16 + name="body" 17 + class="w-full p-2 rounded border" 18 + rows=8 19 + placeholder="Add to the discussion..."></textarea 20 20 > 21 21 {{ template "replyActions" . }} 22 22 <div id="pull-comment"></div> ··· 47 47 {{ define "reply" }} 48 48 <button 49 49 type="submit" 50 - id="reply-{{ .RoundNumber }}" 51 50 class="btn-create flex items-center gap-2"> 52 51 {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 53 52 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 54 53 reply 55 54 </button> 56 55 {{ end }} 57 -
+28 -7
appview/pages/templates/repo/pulls/pull.html
··· 592 592 </summary> 593 593 <div> 594 594 {{ range $item.Comments }} 595 - {{ template "submissionComment" . }} 595 + {{/* template "submissionComment" . */}} 596 + {{ template "comment" 597 + (dict "LoggedInUser" $root.LoggedInUser 598 + "Reactions" (index (asReactionMapMap $root.Reactions) .AtUri) 599 + "UserReacted" (index (asReactionStatusMapMap $root.UserReacted) .AtUri) 600 + "Comment" .) }} 596 601 {{ end }} 597 602 </div> 598 603 {{ if gt $c 0}} ··· 607 612 {{ block "resubmitStatus" $root }} {{ end }} 608 613 {{ end }} 609 614 </div> 610 - <div class="relative -ml-10 bg-gray-50 dark:bg-gray-900"> 615 + <div hx-include="this" class="relative -ml-10 bg-gray-50 dark:bg-gray-900"> 611 616 {{ if $root.LoggedInUser }} 617 + <input name="subject-uri" type="hidden" value="{{ $root.Pull.AtUri }}"> 618 + <input name="pull-round-idx" type="hidden" value="{{ $item.RoundNumber }}"> 612 619 {{ template "repo/pulls/fragments/pullActions" 613 620 (dict 614 621 "LoggedInUser" $root.LoggedInUser ··· 624 631 </details> 625 632 {{ end }} 626 633 634 + {{ define "comment" }} 635 + <div class="flex gap-2 -ml-4 py-4 w-full mx-auto"> 636 + <!-- left column: profile picture --> 637 + <div class="flex-shrink-0 h-fit relative"> 638 + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8") }} 639 + </div> 640 + <!-- right column: name and body in two rows --> 641 + <div class="flex-1 min-w-0"> 642 + {{ template "fragments/comment/commentHeader" . }} 643 + {{ template "fragments/comment/commentBody" . }} 644 + </div> 645 + </div> 646 + {{ end }} 647 + 627 648 {{ define "submissionComment" }} 628 - <div id="comment-{{.ID}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto group/comment"> 649 + <div id="comment-{{.Id}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto group/comment"> 629 650 <!-- left column: profile picture --> 630 651 <div class="flex-shrink-0 h-fit relative"> 631 - {{ template "user/fragments/picLink" (list .OwnerDid "size-8") }} 652 + {{ template "user/fragments/picLink" (list .Did.String "size-8") }} 632 653 </div> 633 654 <!-- right column: name and body in two rows --> 634 655 <div class="flex-1 min-w-0"> 635 656 <!-- Row 1: Author and timestamp --> 636 657 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1 group-target/comment:bg-yellow-200/30 group-target/comment:dark:bg-yellow-600/30"> 637 - {{ $handle := resolve .OwnerDid }} 658 + {{ $handle := resolve .Did.String }} 638 659 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="/{{ $handle }}">{{ $handle }}</a> 639 660 <span class="before:content-['ยท']"></span> 640 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"> 661 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.Id}}"> 641 662 {{ template "repo/fragments/shortTime" .Created }} 642 663 </a> 643 664 </div> 644 665 <!-- Row 2: Body text --> 645 666 <div class="prose dark:prose-invert mt-1"> 646 - {{ .Body | markdown }} 667 + {{ .Body.Text | markdown }} 647 668 </div> 648 669 </div> 649 670 </div>
+59
appview/pages/templates/strings/string.html
··· 94 94 </div> 95 95 {{ template "fragments/multiline-select" }} 96 96 </section> 97 + <div class="flex flex-col gap-4 mt-4"> 98 + {{ 99 + template "fragments/comment/commentList" 100 + (dict 101 + "LoggedInUser" $.LoggedInUser 102 + "CommentList" $.CommentList 103 + "Reactions" $.Reactions 104 + "UserReacted" $.UserReacted) 105 + }} 106 + {{ template "newComment" . }} 107 + </div> 108 + {{ end }} 109 + 110 + {{ define "newComment" }} 111 + {{ if .LoggedInUser }} 112 + <form 113 + hx-post="/comment" 114 + hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:find textarea" 115 + hx-swap="none" 116 + hx-disabled-elt="find button[type='submit']" 117 + hx-on::after-request="if(event.detail.successful) this.reset()" 118 + class="group/form" 119 + > 120 + <input name="subject-uri" type="hidden" value="{{ .String.AtUri }}"> 121 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> 122 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 123 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 124 + </div> 125 + <textarea 126 + name="body" 127 + class="w-full p-2 rounded" 128 + placeholder="Add to the discussion. Markdown is supported." 129 + rows="5" 130 + required 131 + ></textarea> 132 + <div id="comment-error" class="error"></div> 133 + </div> 134 + <div class="flex gap-2 mt-2"> 135 + <button 136 + id="comment-button" 137 + type="submit" 138 + class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline" 139 + > 140 + {{ i "message-square-plus" "w-4 h-4 inline group-[.htmx-request]/form:hidden" }} 141 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]/form:inline" }} 142 + comment 143 + </button> 144 + </div> 145 + </form> 146 + {{ else }} 147 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 148 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 149 + sign up 150 + </a> 151 + <span class="text-gray-500 dark:text-gray-400">or</span> 152 + <a href="/login" class="underline">login</a> 153 + to add to the discussion 154 + </div> 155 + {{ end }} 97 156 {{ end }}
+17 -86
appview/pulls/pulls.go
··· 240 240 m[p.Sha] = p 241 241 } 242 242 243 - reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 243 + entities := []syntax.ATURI{pull.AtUri()} 244 + for _, s := range pull.Submissions { 245 + for _, c := range s.Comments { 246 + entities = append(entities, c.AtUri()) 247 + } 248 + } 249 + reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20) 244 250 if err != nil { 245 251 l.Error("failed to get pull reactions", "err", err) 246 252 } 247 253 248 - userReactions := map[models.ReactionKind]bool{} 254 + var userReactions map[syntax.ATURI]map[models.ReactionKind]bool 249 255 if user != nil { 250 - userReactions = db.GetReactionStatusMap(s.db, user.Active.Did, pull.AtUri()) 256 + userReactions, err = db.ListReactionStatusMap(s.db, entities, syntax.DID(user.Active.Did)) 257 + if err != nil { 258 + s.logger.Error("failed to get user reactions", "err", err) 259 + } 251 260 } 252 261 253 262 labelDefs, err := db.GetLabelDefinitions( ··· 288 297 diff = patchutil.Interdiff(previousPatch, currentPatch) 289 298 } 290 299 291 - s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 300 + err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 292 301 LoggedInUser: user, 293 302 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 294 303 Pull: pull, ··· 303 312 ActiveRound: roundIdInt, 304 313 IsInterdiff: interdiff, 305 314 306 - Reactions: reactionMap, 315 + Reactions: reactions, 307 316 UserReacted: userReactions, 308 317 309 318 LabelDefs: defs, 310 319 }) 320 + if err != nil { 321 + s.logger.Error("failed to render", "err", err) 322 + } 311 323 } 312 324 313 325 func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { ··· 803 815 l = l.With("user", user.Active.Did) 804 816 } 805 817 806 - f, err := s.repoResolver.Resolve(r) 807 - if err != nil { 808 - l.Error("failed to get repo and knot", "err", err) 809 - return 810 - } 811 - 812 818 pull, ok := r.Context().Value("pull").(*models.Pull) 813 819 if !ok { 814 820 l.Error("failed to get pull") ··· 833 839 Pull: pull, 834 840 RoundNumber: roundNumber, 835 841 }) 836 - return 837 - case http.MethodPost: 838 - body := r.FormValue("body") 839 - if body == "" { 840 - s.pages.Notice(w, "pull", "Comment body is required") 841 - return 842 - } 843 - 844 - mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 845 - 846 - // Start a transaction 847 - tx, err := s.db.BeginTx(r.Context(), nil) 848 - if err != nil { 849 - l.Error("failed to start transaction", "err", err) 850 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 851 - return 852 - } 853 - defer tx.Rollback() 854 - 855 - createdAt := time.Now().Format(time.RFC3339) 856 - 857 - client, err := s.oauth.AuthorizedClient(r) 858 - if err != nil { 859 - l.Error("failed to get authorized client", "err", err) 860 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 861 - return 862 - } 863 - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 864 - Collection: tangled.RepoPullCommentNSID, 865 - Repo: user.Active.Did, 866 - Rkey: tid.TID(), 867 - Record: &lexutil.LexiconTypeDecoder{ 868 - Val: &tangled.RepoPullComment{ 869 - Pull: pull.AtUri().String(), 870 - Body: body, 871 - CreatedAt: createdAt, 872 - }, 873 - }, 874 - }) 875 - if err != nil { 876 - l.Error("failed to create pull comment", "err", err) 877 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 878 - return 879 - } 880 - 881 - comment := &models.PullComment{ 882 - OwnerDid: user.Active.Did, 883 - RepoAt: f.RepoAt().String(), 884 - PullId: pull.PullId, 885 - Body: body, 886 - CommentAt: atResp.Uri, 887 - SubmissionId: pull.Submissions[roundNumber].ID, 888 - Mentions: mentions, 889 - References: references, 890 - } 891 - 892 - // Create the pull comment in the database with the commentAt field 893 - commentId, err := db.NewPullComment(tx, comment) 894 - if err != nil { 895 - l.Error("failed to create pull comment in database", "err", err) 896 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 897 - return 898 - } 899 - 900 - // Commit the transaction 901 - if err = tx.Commit(); err != nil { 902 - l.Error("failed to commit transaction", "err", err) 903 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 904 - return 905 - } 906 - 907 - s.notifier.NewPullComment(r.Context(), comment, mentions) 908 - 909 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 910 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 911 842 return 912 843 } 913 844 }
+1 -4
appview/pulls/router.go
··· 29 29 r.Get("/", s.RepoPullPatch) 30 30 r.Get("/interdiff", s.RepoPullInterdiff) 31 31 r.Get("/actions", s.PullActions) 32 - r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) { 33 - r.Get("/", s.PullComment) 34 - r.Post("/", s.PullComment) 35 - }) 32 + r.Get("/comment", s.PullComment) 36 33 }) 37 34 38 35 r.Route("/round/{round}.patch", func(r chi.Router) {
+396
appview/state/comment.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strconv" 7 + "time" 8 + 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + lexutil "github.com/bluesky-social/indigo/lex/util" 12 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 + 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/models" 17 + "tangled.org/core/appview/pages" 18 + "tangled.org/core/orm" 19 + "tangled.org/core/tid" 20 + ) 21 + 22 + func (s *State) CommentBodyFragment(w http.ResponseWriter, r *http.Request) { 23 + l := s.logger.With("handler", "CommentBodyFragment") 24 + 25 + commentAt := r.URL.Query().Get("aturi") 26 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 27 + if err != nil { 28 + l.Error("failed to fetch comment", "aturi", commentAt) 29 + http.Error(w, "Failed to fetch comment", http.StatusInternalServerError) 30 + return 31 + } 32 + 33 + err = s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 34 + Comment: comment, 35 + }) 36 + if err != nil { 37 + l.Error("failed to render") 38 + } 39 + } 40 + 41 + func (s *State) EditCommentFragment(w http.ResponseWriter, r *http.Request) { 42 + l := s.logger.With("handler", "EditCommentFragment") 43 + 44 + commentAt := r.URL.Query().Get("aturi") 45 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 46 + if err != nil { 47 + l.Error("failed to fetch comment", "aturi", commentAt) 48 + http.Error(w, "Failed to fetch comment", http.StatusInternalServerError) 49 + return 50 + } 51 + 52 + err = s.pages.EditCommentFragment(w, pages.EditCommentFragmentParams{ 53 + Comment: comment, 54 + }) 55 + if err != nil { 56 + l.Error("failed to render") 57 + } 58 + } 59 + 60 + func (s *State) NewReplyCommentFragment(w http.ResponseWriter, r *http.Request) { 61 + s.pages.ReplyCommentFragment(w, pages.ReplyCommentFragmentParams{ 62 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 63 + }) 64 + } 65 + 66 + func (s *State) ReplyPlaceholderFragment(w http.ResponseWriter, r *http.Request) { 67 + s.pages.ReplyPlaceholderFragment(w, pages.ReplyPlaceholderFragmentParams{ 68 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 69 + }) 70 + } 71 + 72 + func (s *State) NewComment(w http.ResponseWriter, r *http.Request) { 73 + l := s.logger.With("handler", "NewComment") 74 + user := s.oauth.GetMultiAccountUser(r) 75 + 76 + noticeId := "comment-error" 77 + ctx := r.Context() 78 + 79 + body := r.FormValue("body") 80 + if body == "" { 81 + s.pages.Notice(w, noticeId, "Body is required") 82 + return 83 + } 84 + 85 + // TODO(boltless): normalize markdown body 86 + normalizedBody := body 87 + _, references := s.mentionsResolver.Resolve(ctx, body) 88 + 89 + markdownBody := tangled.MarkupMarkdown{ 90 + Text: normalizedBody, 91 + Original: &body, 92 + Blobs: nil, 93 + } 94 + 95 + subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri")) 96 + if err != nil { 97 + l.Warn("invalid subject uri", "err", err) 98 + s.pages.Notice(w, noticeId, "Subject URI should be valid AT-URI") 99 + return 100 + } 101 + l = l.With("subject.uri", subjectUri) 102 + 103 + // ingest CID of subject record on-demand. 104 + // TODO(boltless): appview should ingest CID of all atproto records 105 + var subjectCid syntax.CID 106 + if subjectCidRaw := r.FormValue("subject-cid"); subjectCidRaw != "" { 107 + subjectCid, err = syntax.ParseCID(subjectCidRaw) 108 + if err != nil { 109 + l.Warn("invalid subject cid", "err", err) 110 + s.pages.Notice(w, noticeId, "Subject URI should be valid AT-URI") 111 + return 112 + } 113 + } else { 114 + l.Debug("ingesting subject record CID") 115 + subjectCid, err = func(uri syntax.ATURI) (syntax.CID, error) { 116 + ident, err := s.idResolver.ResolveIdent(ctx, uri.Authority().String()) 117 + if err != nil { 118 + return "", err 119 + } 120 + 121 + xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 122 + out, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) 123 + if err != nil { 124 + return "", err 125 + } 126 + if out.Cid == nil { 127 + return "", fmt.Errorf("record CID is empty") 128 + } 129 + 130 + cid, err := syntax.ParseCID(*out.Cid) 131 + if err != nil { 132 + return "", err 133 + } 134 + 135 + return cid, nil 136 + }(subjectUri) 137 + if err != nil { 138 + l.Error("failed to backfill subject record", "err", err) 139 + s.pages.Notice(w, noticeId, "failed to backfill subject record") 140 + return 141 + } 142 + } 143 + l = l.With("subject.cid", subjectCid) 144 + 145 + subject := comatproto.RepoStrongRef{ 146 + Uri: subjectUri.String(), 147 + Cid: subjectCid.String(), 148 + } 149 + 150 + var pullRoundIdx *int 151 + if pullRoundIdxRaw := r.FormValue("pull-round-idx"); pullRoundIdxRaw != "" { 152 + roundIdx, err := strconv.Atoi(pullRoundIdxRaw) 153 + if err != nil { 154 + l.Warn("invalid round idx", "err", err) 155 + s.pages.Notice(w, noticeId, "pull round index should be valid integer") 156 + return 157 + } 158 + pullRoundIdx = &roundIdx 159 + } 160 + 161 + var replyTo *comatproto.RepoStrongRef 162 + replyToUriRaw := r.FormValue("reply-to-uri") 163 + replyToCidRaw := r.FormValue("reply-to-cid") 164 + if replyToUriRaw != "" && replyToCidRaw != "" { 165 + uri, err := syntax.ParseATURI(replyToUriRaw) 166 + if err != nil { 167 + s.pages.Notice(w, noticeId, "reply-to-uri should be valid AT-URI") 168 + return 169 + } 170 + cid, err := syntax.ParseCID(replyToCidRaw) 171 + if err != nil { 172 + s.pages.Notice(w, noticeId, "reply-to-cid should be valid CID") 173 + return 174 + } 175 + replyTo = &comatproto.RepoStrongRef{ 176 + Uri: uri.String(), 177 + Cid: cid.String(), 178 + } 179 + } 180 + 181 + comment := models.Comment{ 182 + Did: syntax.DID(user.Active.Did), 183 + Collection: tangled.FeedCommentNSID, 184 + Rkey: syntax.RecordKey(tid.TID()), 185 + 186 + Subject: subject, 187 + Body: markdownBody, 188 + Created: time.Now(), 189 + ReplyTo: replyTo, 190 + PullRoundIdx: pullRoundIdx, 191 + } 192 + if err = comment.Validate(); err != nil { 193 + l.Error("failed to validate comment", "err", err) 194 + s.pages.Notice(w, noticeId, "Failed to create comment.") 195 + return 196 + } 197 + 198 + client, err := s.oauth.AuthorizedClient(r) 199 + if err != nil { 200 + l.Error("failed to get authorized client", "err", err) 201 + s.pages.Notice(w, noticeId, "Failed to create comment.") 202 + return 203 + } 204 + 205 + // create a record first 206 + out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 207 + Collection: comment.Collection.String(), 208 + Repo: comment.Did.String(), 209 + Rkey: comment.Rkey.String(), 210 + Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 211 + }) 212 + if err != nil { 213 + l.Error("failed to create comment", "err", err) 214 + s.pages.Notice(w, noticeId, "Failed to create comment.") 215 + return 216 + } 217 + 218 + comment.Cid = syntax.CID(out.Cid) 219 + 220 + tx, err := s.db.Begin() 221 + if err != nil { 222 + l.Error("failed to start transaction", "err", err) 223 + s.pages.Notice(w, noticeId, "Failed to create comment, try again later.") 224 + return 225 + } 226 + defer tx.Rollback() 227 + 228 + err = db.PutComment(tx, &comment, references) 229 + if err != nil { 230 + l.Error("failed to create comment", "err", err) 231 + s.pages.Notice(w, noticeId, "Failed to create comment.") 232 + return 233 + } 234 + 235 + err = tx.Commit() 236 + if err != nil { 237 + l.Error("failed to commit transaction", "err", err) 238 + s.pages.Notice(w, noticeId, "Failed to create comment, try again later.") 239 + return 240 + } 241 + 242 + // TODO: return comment or reply-comment fragment 243 + // onattach, htmx-callback to focus on comment. 244 + s.pages.HxRefresh(w) 245 + } 246 + 247 + func (s *State) EditComment(w http.ResponseWriter, r *http.Request) { 248 + l := s.logger.With("handler", "EditComment") 249 + user := s.oauth.GetMultiAccountUser(r) 250 + 251 + noticeId := "comment-error" 252 + ctx := r.Context() 253 + 254 + commentAt := r.FormValue("aturi") 255 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 256 + if err != nil { 257 + l.Error("failed to fetch comment", "aturi", commentAt, "err", err) 258 + s.pages.Notice(w, noticeId, "Failed to fetch comment") 259 + return 260 + } 261 + 262 + if comment.Did.String() != user.Active.Did { 263 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) 264 + s.pages.Notice(w, noticeId, "You are not the author of this comment") 265 + return 266 + } 267 + 268 + body := r.FormValue("body") 269 + if body == "" { 270 + s.pages.Notice(w, noticeId, "Body is required") 271 + return 272 + } 273 + 274 + // TODO(boltless): normalize markdown body 275 + normalizedBody := body 276 + _, references := s.mentionsResolver.Resolve(ctx, body) 277 + 278 + now := time.Now() 279 + newComment := comment 280 + newComment.Body = tangled.MarkupMarkdown{ 281 + Text: normalizedBody, 282 + Original: &body, 283 + Blobs: nil, 284 + } 285 + newComment.Edited = &now 286 + if err := newComment.Validate(); err != nil { 287 + l.Error("failed to validate comment", "err", err) 288 + s.pages.Notice(w, noticeId, "Failed to update comment.") 289 + return 290 + } 291 + 292 + client, err := s.oauth.AuthorizedClient(r) 293 + if err != nil { 294 + l.Error("failed to get authorized client", "err", err) 295 + s.pages.Notice(w, noticeId, "Failed to create comment. try again later.") 296 + return 297 + } 298 + 299 + // update the record first 300 + exCid := comment.Cid.String() 301 + out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 302 + Collection: newComment.Collection.String(), 303 + Repo: newComment.Did.String(), 304 + Rkey: newComment.Rkey.String(), 305 + SwapRecord: &exCid, 306 + Record: &lexutil.LexiconTypeDecoder{ 307 + Val: newComment.AsRecord(), 308 + }, 309 + }) 310 + if err != nil { 311 + l.Error("failed to update comment", "err", err) 312 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 313 + return 314 + } 315 + 316 + newComment.Cid = syntax.CID(out.Cid) 317 + 318 + tx, err := s.db.Begin() 319 + if err != nil { 320 + l.Error("failed to start transaction", "err", err) 321 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 322 + return 323 + } 324 + defer tx.Rollback() 325 + 326 + err = db.PutComment(tx, &newComment, references) 327 + if err != nil { 328 + l.Error("failed to perform update-description query", "err", err) 329 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 330 + return 331 + } 332 + err = tx.Commit() 333 + if err != nil { 334 + l.Error("failed to commit transaction", "err", err) 335 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 336 + return 337 + } 338 + 339 + // TODO: return full comment fragment so we can update comment header too 340 + s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 341 + Comment: newComment, 342 + }) 343 + } 344 + 345 + func (s *State) DeleteComment(w http.ResponseWriter, r *http.Request) { 346 + l := s.logger.With("handler", "DeleteComment") 347 + user := s.oauth.GetMultiAccountUser(r) 348 + 349 + noticeId := "comment" 350 + ctx := r.Context() 351 + 352 + commentAt := r.URL.Query().Get("aturi") 353 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 354 + if err != nil { 355 + l.Error("failed to fetch comment", "aturi", commentAt) 356 + s.pages.Notice(w, noticeId, "Failed to fetch comment.") 357 + return 358 + } 359 + 360 + if comment.Did.String() != user.Active.Did { 361 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) 362 + s.pages.Notice(w, noticeId, "you are not the author of this comment") 363 + return 364 + } 365 + 366 + if comment.Deleted != nil { 367 + s.pages.Notice(w, noticeId, "Comment already deleted") 368 + return 369 + } 370 + 371 + client, err := s.oauth.AuthorizedClient(r) 372 + if err != nil { 373 + l.Error("failed to get authorized client", "err", err) 374 + s.pages.Notice(w, "comment", "Failed to delete comment.") 375 + return 376 + } 377 + _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 378 + Collection: comment.Collection.String(), 379 + Repo: comment.Did.String(), 380 + Rkey: comment.Rkey.String(), 381 + }) 382 + if err != nil { 383 + l.Error("failed to delete from PDS", "err", err) 384 + s.pages.Notice(w, noticeId, "Failed to delete comment, try again later.") 385 + return 386 + } 387 + 388 + // optimistic update for htmx response 389 + now := time.Now() 390 + comment.Body = tangled.MarkupMarkdown{} 391 + comment.Deleted = &now 392 + 393 + s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 394 + Comment: comment, 395 + }) 396 + }
+1 -3
appview/state/reaction.go
··· 19 19 l := s.logger.With("handler", "React") 20 20 currentUser := s.oauth.GetMultiAccountUser(r) 21 21 22 - subject := r.URL.Query().Get("subject") 22 + subject := r.FormValue("subject-uri") 23 23 if subject == "" { 24 24 l.Warn("invalid form") 25 25 return ··· 78 78 l.Info("created atproto record", "uri", resp.Uri) 79 79 80 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 - ThreadAt: subjectUri, 82 81 Kind: reactionKind, 83 82 Count: reactionMap[reactionKind].Count, 84 83 Users: reactionMap[reactionKind].Users, ··· 117 116 } 118 117 119 118 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 120 - ThreadAt: subjectUri, 121 119 Kind: reactionKind, 122 120 Count: reactionMap[reactionKind].Count, 123 121 Users: reactionMap[reactionKind].Users,
+17
appview/state/router.go
··· 12 12 "tangled.org/core/appview/knots" 13 13 "tangled.org/core/appview/labels" 14 14 "tangled.org/core/appview/middleware" 15 + "tangled.org/core/appview/migration" 15 16 "tangled.org/core/appview/notifications" 16 17 "tangled.org/core/appview/pipelines" 17 18 "tangled.org/core/appview/pulls" ··· 35 36 s.pages, 36 37 s.logger, 37 38 ) 39 + 40 + // TODO(boltless): merge this into BackgroundMigrationMiddleware 41 + router.Use(s.oauth.PdsRewriteMiddleware) 42 + 43 + m := migration.NewMigration(s.db, s.oauth, s.idResolver.Directory(), s.logger) 44 + router.Use(m.BackgroundMigrationMiddleware) 38 45 39 46 router.Get("/pwa-manifest.json", s.WebAppManifest) 40 47 router.Get("/robots.txt", s.RobotsTxt) ··· 186 193 r.With(middleware.AuthMiddleware(s.oauth)).Route("/react", func(r chi.Router) { 187 194 r.Post("/", s.React) 188 195 r.Delete("/", s.React) 196 + }) 197 + 198 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) { 199 + r.Get("/", s.CommentBodyFragment) 200 + r.Get("/edit", s.EditCommentFragment) 201 + r.Get("/reply", s.NewReplyCommentFragment) 202 + r.Get("/reply/placeholder", s.ReplyPlaceholderFragment) 203 + r.Post("/", s.NewComment) 204 + r.Patch("/", s.EditComment) 205 + r.Delete("/", s.DeleteComment) 189 206 }) 190 207 191 208 r.Route("/profile", func(r chi.Router) {
+17 -13
appview/state/state.go
··· 126 126 tangled.KnotNSID, 127 127 tangled.StringNSID, 128 128 tangled.RepoPullNSID, 129 + tangled.RepoPullCommentNSID, 129 130 tangled.RepoIssueNSID, 130 131 tangled.RepoIssueCommentNSID, 132 + tangled.FeedCommentNSID, 131 133 tangled.LabelDefinitionNSID, 132 134 tangled.LabelOpNSID, 133 135 }, ··· 146 148 147 149 if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil { 148 150 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 149 - } 150 - 151 - ingester := appview.Ingester{ 152 - Db: wrapper, 153 - Enforcer: enforcer, 154 - IdResolver: res, 155 - Config: config, 156 - Logger: log.SubLogger(logger, "ingester"), 157 - Validator: validator, 158 - } 159 - err = jc.StartJetstream(ctx, ingester.Ingest()) 160 - if err != nil { 161 - return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 162 151 } 163 152 164 153 var notifiers []notify.Notifier ··· 176 165 177 166 notifier := notify.NewMergedNotifier(notifiers) 178 167 notifier = lognotify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify")) 168 + 169 + ingester := appview.Ingester{ 170 + Db: wrapper, 171 + Enforcer: enforcer, 172 + IdResolver: res, 173 + Config: config, 174 + Logger: log.SubLogger(logger, "ingester"), 175 + Validator: validator, 176 + MentionsResolver: mentionsResolver, 177 + Notifier: notifier, 178 + } 179 + err = jc.StartJetstream(ctx, ingester.Ingest()) 180 + if err != nil { 181 + return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 182 + } 179 183 180 184 var cfClient *cloudflare.Client 181 185 if config.Cloudflare.ApiToken != "" {
+25 -6
appview/strings/strings.go
··· 55 55 r.Get("/raw", s.contents) 56 56 r.Get("/edit", s.edit) 57 57 r.Post("/edit", s.edit) 58 - r. 59 - With(middleware.AuthMiddleware(s.OAuth)). 60 - Post("/comment", s.comment) 61 58 }) 62 59 }) 63 60 ··· 159 156 isStarred = db.GetStarStatus(s.Db, user.Active.Did, string.AtUri()) 160 157 } 161 158 159 + comments, err := db.GetComments(s.Db, orm.FilterEq("subject_uri", string.AtUri())) 160 + if err != nil { 161 + l.Error("failed to get comments", "err", err) 162 + } 163 + 164 + entities := []syntax.ATURI{string.AtUri()} 165 + for _, c := range comments { 166 + entities = append(entities, c.AtUri()) 167 + } 168 + reactions, err := db.ListReactionDisplayDataMap(s.Db, entities, 20) 169 + if err != nil { 170 + l.Error("failed to get reactions", "err", err) 171 + } 172 + 173 + var userReactions map[syntax.ATURI]map[models.ReactionKind]bool 174 + if user != nil { 175 + userReactions, err = db.ListReactionStatusMap(s.Db, entities, syntax.DID(user.Active.Did)) 176 + if err != nil { 177 + l.Error("failed to get user reactions", "err", err) 178 + } 179 + } 180 + 162 181 s.Pages.SingleString(w, pages.SingleStringParams{ 163 182 LoggedInUser: user, 164 183 RenderToggle: renderToggle, ··· 168 187 IsStarred: isStarred, 169 188 StarCount: starCount, 170 189 Owner: id, 190 + CommentList: models.NewCommentList(comments), 191 + Reactions: reactions, 192 + UserReacted: userReactions, 171 193 }) 172 194 } 173 195 ··· 436 458 437 459 s.Pages.HxRedirect(w, "/strings/"+user.Active.Did) 438 460 } 439 - 440 - func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 441 - }
-27
appview/validator/issue.go
··· 4 4 "fmt" 5 5 "strings" 6 6 7 - "tangled.org/core/appview/db" 8 7 "tangled.org/core/appview/models" 9 - "tangled.org/core/orm" 10 8 ) 11 - 12 - func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 13 - // if comments have parents, only ingest ones that are 1 level deep 14 - if comment.ReplyTo != nil { 15 - parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) 16 - if err != nil { 17 - return fmt.Errorf("failed to fetch parent comment: %w", err) 18 - } 19 - if len(parents) != 1 { 20 - return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 21 - } 22 - 23 - // depth check 24 - parent := parents[0] 25 - if parent.ReplyTo != nil { 26 - return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 27 - } 28 - } 29 - 30 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 31 - return fmt.Errorf("body is empty after HTML sanitization") 32 - } 33 - 34 - return nil 35 - } 36 9 37 10 func (v *Validator) ValidateIssue(issue *models.Issue) error { 38 11 if issue.Title == "" {
+2
cmd/cborgen/cborgen.go
··· 15 15 "api/tangled/cbor_gen.go", 16 16 "tangled", 17 17 tangled.ActorProfile{}, 18 + tangled.FeedComment{}, 18 19 tangled.FeedReaction{}, 19 20 tangled.FeedStar{}, 20 21 tangled.GitRefUpdate{}, ··· 30 31 tangled.LabelDefinition_ValueType{}, 31 32 tangled.LabelOp{}, 32 33 tangled.LabelOp_Operand{}, 34 + tangled.MarkupMarkdown{}, 33 35 tangled.Pipeline{}, 34 36 tangled.Pipeline_CloneOpts{}, 35 37 tangled.Pipeline_ManualTriggerData{},
+6
lexicon-build-config.json
··· 1 1 [ 2 2 { 3 + "package": "atproto", 4 + "prefix": "com.atproto", 5 + "outdir": "/tmp/atproto", 6 + "import": "github.com/bluesky-social/indigo/api/atproto" 7 + }, 8 + { 3 9 "package": "tangled", 4 10 "prefix": "sh.tangled", 5 11 "outdir": "api/tangled",
+15
lexicons/com/atproto/repo/strongRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": ["uri", "cid"], 9 + "properties": { 10 + "uri": { "type": "string", "format": "at-uri" }, 11 + "cid": { "type": "string", "format": "cid" } 12 + } 13 + } 14 + } 15 + }
+43
lexicons/feed/comment.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.feed.comment", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "body", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "ref", 20 + "ref": "com.atproto.repo.strongRef" 21 + }, 22 + "body": { 23 + "type": "union", 24 + "refs": ["sh.tangled.markup.markdown"] 25 + }, 26 + "createdAt": { 27 + "type": "string", 28 + "format": "datetime" 29 + }, 30 + "replyTo": { 31 + "type": "ref", 32 + "ref": "com.atproto.repo.strongRef" 33 + }, 34 + "pullRoundIdx": { 35 + "type": "integer", 36 + "minimum": 0, 37 + "description": "optional pull submission round index. required when subject is sh.tangled.repo.pull" 38 + } 39 + } 40 + } 41 + } 42 + } 43 + }
+30
lexicons/markup/markdown.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.markup.markdown", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": ["text"], 8 + "description": "Tangled Flavored Markdown format text", 9 + "properties": { 10 + "text": { 11 + "type": "string", 12 + "description": "Final post-processed markdown content that will be rendered" 13 + }, 14 + "original": { 15 + "type": "string", 16 + "description": "Original Markdown before post-processing. Used to restore original input on edit." 17 + }, 18 + "blobs": { 19 + "type": "array", 20 + "items": { 21 + "type": "blob", 22 + "accept": ["image/*"], 23 + "maxSize": 1000000 24 + }, 25 + "description": "list of blobs referenced in markdown" 26 + } 27 + } 28 + } 29 + } 30 + }

History

2 rounds 2 comments
sign up or login to add to the discussion
14 commits
expand
lexicons: add general sh.tangled.comment lexicon
lexicons: com.atproto.repo.strongRef and sh.tangled.markup.markdown
appview: replace PullComment to Comment
appview: replace IssueComment to Comment
appview/notify: merge new comment events into one
appview: unified comment fragments/handlers
appview: move CommentList out of Issue
appview: add comment to strings
appview: fetch aturi->reactionMap from DB
appview: add reactions to comments
appview: drain pds rewrites on any active sessions
appview: background pds data migration
appview: migrate legacy comment pds records
wip: appview: unify pds record migration
3/3 failed
expand
merge conflicts detected
expand
  • appview/pulls/pulls.go:837
expand 0 comments
boltless.me submitted #0
13 commits
expand
lexicons: add general sh.tangled.comment lexicon
lexicons: com.atproto.repo.strongRef and sh.tangled.markup.markdown
appview: replace PullComment to Comment
appview: replace IssueComment to Comment
appview/notify: merge new comment events into one
appview: unified comment fragments/handlers
appview: move CommentList out of Issue
appview: add comment to strings
appview: fetch aturi->reactionMap from DB
appview: add reactions to comments
appview: drain pds rewrites on any active sessions
appview: background pds data migration
wip: appview: unify pds record migration
1/3 failed, 2/3 success
expand
expand 2 comments

lovely diff. this PR is rendered as super-big because it depends on sl/comment branch PR#1303.

lovely diff. this PR is rendered as super-big because it depends on sl/comment branch PR#1303.