1package tinyclient23import (4 "encoding/json"5 "io"6 "net/http"7 "net/url"8 "strings"9)1011func (a *App) handleSocialRoute(w http.ResponseWriter, r *http.Request) bool {12 p := r.URL.Path13 if p != "/articles" && p != "/social" && p != "/social.json" && p != "/social.xml" && !strings.HasPrefix(p, "/social/") {14 return false15 }16 if !a.backend.Policy().Features.Pages {17 http.NotFound(w, r)18 return true19 }20 if p == "/social/preview" && r.Method == http.MethodPost {21 a.socialPreviewPage(w, r)22 return true23 }24 if r.Method != http.MethodGet && r.Method != http.MethodHead {25 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)26 return true27 }28 if p == "/articles" {29 target := requestPrefix(r) + "/social"30 if r.URL.RawQuery != "" {31 target += "?" + r.URL.RawQuery32 }33 http.Redirect(w, r, target, http.StatusMovedPermanently)34 return true35 }36 if p == "/social.json" || p == "/social.xml" {37 a.socialSyndication(w, r)38 return true39 }40 a.socialPage(w, r)41 return true42}4344func socialParameters(q url.Values) map[string]any {45 params := map[string]any{"limit": 30}46 for _, key := range []string{"kind", "author", "q", "cursor", "address"} {47 if value := q.Get(key); value != "" {48 params[key] = value49 }50 }51 if q.Get("kind") == "" {52 switch q.Get("kinds") {53 case "1":54 params["kind"] = "notes"55 case "30023":56 params["kind"] = "articles"57 }58 }59 return params60}6162func (a *App) socialPage(w http.ResponseWriter, r *http.Request) {63 actor, _ := a.resolveActor(r)64 params := socialParameters(r.URL.Query())65 method, tab := "browsesocial", "social"66 if strings.HasPrefix(r.URL.Path, "/social/") {67 params["id"] = strings.TrimPrefix(r.URL.Path, "/social/")68 method, tab = "browsesocialthread", "social-thread"69 } else if r.URL.Query().Get("address") != "" {70 method, tab = "browsesocialthread", "social-thread"71 }72 raw, err := json.Marshal(params)73 if err != nil {74 http.Error(w, "invalid social query", http.StatusBadRequest)75 return76 }77 result, err := a.backend.Query(r.Context(), method, []json.RawMessage{raw}, actor)78 data := PageData{Tab: tab, Title: "Social | " + a.backend.Slug(), Event: result, Query: r.URL.Query()}79 if tab == "social" {80 data.Feed = browseRows(result)81 }82 if err != nil {83 data.Error = err.Error()84 if strings.HasPrefix(err.Error(), "not found:") {85 w.WriteHeader(http.StatusNotFound)86 }87 }88 a.render(w, r, data)89}9091func (a *App) socialPreviewPage(w http.ResponseWriter, r *http.Request) {92 actor, err := a.resolveActor(r)93 if err != nil || actor == "" {94 http.Error(w, "Sign in to preview a draft.", http.StatusUnauthorized)95 return96 }97 var input struct {98 Content string `json:"content"`99 Kind int `json:"kind"`100 }101 decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256*1024))102 decoder.DisallowUnknownFields()103 if err := decoder.Decode(&input); err != nil {104 http.Error(w, "Invalid preview.", http.StatusBadRequest)105 return106 }107 if decoder.Decode(new(any)) != io.EOF || (input.Kind != 1 && input.Kind != 30023 && input.Kind != 1111) {108 http.Error(w, "Invalid preview.", http.StatusBadRequest)109 return110 }111 w.Header().Set("Cache-Control", "private, no-store")112 writeJSON(w, http.StatusOK, map[string]any{"html": a.socialBody(map[string]any{"content": input.Content, "kind": input.Kind})})113}114115func socialContext(value any, data PageData) map[string]any {116 row := valueMap(value)117 out := make(map[string]any, len(row)+4)118 for key, value := range row {119 out[key] = value120 }121 out["viewer"] = data.Actor122 out["detail"] = data.Tab == "social-thread"123 out["relay"] = wsURL(data.URL)124 out["thread"] = valueMap(data.Event)["post"]125 out["parent_url"] = socialReferenceURL(row, "parent_")126 out["root_url"] = socialReferenceURL(row, "root_")127 parent := plainString(row["parent_id"])128 thread := valueMap(out["thread"])129 out["nested"] = parent != "" && parent != plainString(thread["id"])130 return out131}132133func socialReferenceURL(row map[string]any, prefix string) string {134 if address := plainString(row[prefix+"address"]); strings.HasPrefix(address, "30023:") {135 return "/social?address=" + url.QueryEscape(address)136 }137 if id := plainString(row[prefix+"id"]); hexID.MatchString(id) {138 return "/social/" + id139 }140 return ""141}142143func socialThreadPage(path string, query url.Values, cursor string) string {144 next := url.Values{}145 if address := query.Get("address"); address != "" {146 next.Set("address", address)147 }148 next.Set("cursor", cursor)149 return path + "?" + next.Encode()150}151152func socialReactionCount(value any, content string) int {153 for _, row := range browseRows(valueMap(value)["reactions"]) {154 if plainString(valueMap(row)["content"]) == content {155 return int(unixSeconds(valueMap(row)["count"]))156 }157 }158 return 0159}160161func socialReacted(value any, content string) bool {162 for _, row := range browseRows(valueMap(value)["reactions"]) {163 if plainString(valueMap(row)["content"]) == content {164 return valueMap(row)["reacted"] == true165 }166 }167 return false168}169170func socialQuery(query url.Values, key, value string) string {171 next := url.Values{}172 for _, name := range []string{"kind", "author", "q", "compose", "address"} {173 if v := query.Get(name); v != "" {174 next.Set(name, v)175 }176 }177 if value == "" {178 next.Del(key)179 } else {180 next.Set(key, value)181 }182 if encoded := next.Encode(); encoded != "" {183 return "/social?" + encoded184 }185 return "/social"186}187188func socialURL(value any) string {189 row := valueMap(value)190 if address := plainString(row["address"]); address != "" {191 return "/social?address=" + url.QueryEscape(address)192 }193 return "/social/" + url.PathEscape(plainString(row["id"]))194}195196func (a *App) socialSyndication(w http.ResponseWriter, r *http.Request) {197 actor, _ := a.resolveActor(r)198 raw, err := json.Marshal(socialParameters(r.URL.Query()))199 if err != nil {200 http.Error(w, "invalid social query", http.StatusBadRequest)201 return202 }203 result, err := a.backend.Query(r.Context(), "browsesocial", []json.RawMessage{raw}, actor)204 if err != nil {205 http.Error(w, err.Error(), publicErrorStatus(err))206 return207 }208 items := make([]map[string]any, 0)209 for _, row := range browseRows(result) {210 if item, ok := articleItem(row, a.backend.URL()); ok {211 item["url"] = strings.TrimSuffix(a.backend.URL(), "/") + socialURL(row)212 if title := plainString(valueMap(row)["title"]); title != "" {213 item["title"] = title214 }215 items = append(items, item)216 }217 }218 if r.URL.Path == "/social.xml" {219 a.writeAtom(w, items)220 return221 }222 writeJSON(w, http.StatusOK, map[string]any{"version": "https://jsonfeed.org/version/1.1", "title": a.backend.Slug() + " Social", "home_page_url": strings.TrimSuffix(a.backend.URL(), "/") + "/social", "feed_url": strings.TrimSuffix(a.backend.URL(), "/") + "/social.json", "items": items})223}