clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / tinyclient / social_content.go
social_content.go 9.7K
1package tinyclient23import (4 "crypto/rand"5 "crypto/sha256"6 "encoding/hex"7 "html"8 "html/template"9 "net/url"10 "path"11 "regexp"12 "strconv"13 "strings"1415 "github.com/FelineStateMachine/tinyrelay/internal/views"16)1718// socialBody renders user supplied social content with the same small19// Markdown grammar used by chat. Media is deliberately limited to ordinary20// HTTP(S) URLs and native browser elements.21func (a *App) socialBody(value any) template.HTML {22 return socialBodyWith(value, a.blocks())23}2425func socialBody(value any) template.HTML {26 return socialBodyWith(value, nil)27}2829func socialBodyWith(value any, blocks blockRenderer) template.HTML {30 content := plainString(value)31 var attachments []roomAttachment32 breaks := true33 if m := valueMap(value); m != nil {34 content = plainString(m["content"])35 attachments = socialAttachments(m)36 kind := plainString(m["kind"])37 breaks = kind != "30023" && kind != "54"38 }39 var supplemental []roomAttachment40 for _, item := range attachments {41 if !strings.Contains(content, item.URL) {42 supplemental = append(supplemental, item)43 }44 }45 nonce := rand.Text()46 tokenFor := func(raw string) string { return nonce + socialMediaToken(raw) }47 for _, item := range attachments {48 if !breaks {49 content = replaceSocialMedia(content, item.URL, tokenFor(item.URL))50 }51 }52 content = replaceStandaloneSocialMedia(content, attachments, tokenFor)53 var rendered string54 if breaks && valueMap(value) != nil {55 // NIP-01 notes and NIP-22 comments are plaintext. Preserve their56 // newlines and links without interpreting author text as Markdown.57 raw := html.EscapeString(content)58 raw = strings.ReplaceAll(raw, "\n", "<br>")59 rendered = autolinkOutsideTags(raw)60 } else if breaks {61 rendered = string(renderChatMarkdownWith(content, blocks))62 } else {63 rendered = string(renderMarkdownWith(content, blocks))64 }65 for _, item := range attachments {66 token := html.EscapeString(tokenFor(item.URL))67 rendered = strings.ReplaceAll(rendered, "<p>"+token+"</p>", socialMediaFigure(item))68 rendered = strings.ReplaceAll(rendered, token, socialMediaInline(item))69 }70 for _, item := range supplemental {71 rendered += socialMediaFigure(item)72 }73 return template.HTML(socialNostrLinks(socialMediaDimensions(rendered, value)))74}7576func socialMediaDimensions(rendered string, value any) string {77 for _, tag := range roomTags(value) {78 if len(tag) < 2 || tag[0] != "imeta" {79 continue80 }81 var raw, dimensions string82 for _, field := range tag[1:] {83 if strings.HasPrefix(field, "url ") {84 raw = socialMediaURL(strings.TrimPrefix(field, "url "))85 }86 if strings.HasPrefix(field, "dim ") {87 dimensions = strings.TrimPrefix(field, "dim ")88 }89 }90 widthText, heightText, ok := strings.Cut(dimensions, "x")91 if raw == "" || !ok {92 continue93 }94 width, widthErr := strconv.ParseFloat(widthText, 64)95 height, heightErr := strconv.ParseFloat(heightText, 64)96 if widthErr != nil || heightErr != nil || !(width >= 1 && width <= 32000 && height >= 1 && height <= 32000) {97 continue98 }99 prefix := `<img src="` + html.EscapeString(raw) + `" `100 attributes := `width="` + strconv.Itoa(int(width)) + `" height="` + strconv.Itoa(int(height)) + `" `101 rendered = strings.ReplaceAll(rendered, prefix, prefix+attributes)102 }103 return rendered104}105106// socialPreview returns a compact plain-text excerpt suitable for feed cards.107func socialPreview(value any) string {108 text := plainString(value)109 if m := valueMap(value); m != nil {110 text = plainString(m["content"])111 }112 text = regexp.MustCompile("(?s)```.*?```|`([^`]+)`|!\\[([^\\]]*)\\]\\([^)]*\\)").ReplaceAllString(text, "$1$2")113 text = strings.Join(strings.Fields(text), " ")114 if len([]rune(text)) > 280 {115 text = string([]rune(text)[:277]) + "..."116 }117 return text118}119120// socialMediaURL accepts only absolute HTTP(S) URLs without userinfo.121func socialMediaURL(raw string) string {122 raw = strings.TrimSpace(raw)123 u, err := url.Parse(raw)124 if err != nil || u.User != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") {125 return ""126 }127 return u.String()128}129130var socialImage = regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^)\s]+)\)`)131132func socialMediaToken(raw string) string {133 digest := sha256.Sum256([]byte(raw))134 return "social-media-" + hex.EncodeToString(digest[:])135}136137func replaceSocialMedia(content, raw, replacement string) string {138 return replaceSocialOutsideCode(content, func(part string) string {139 return roomAttachmentReference.ReplaceAllStringFunc(part, func(match string) string {140 parts := roomAttachmentReference.FindStringSubmatch(match)141 if len(parts) == 4 && parts[2] == raw {142 return replacement143 }144 return match145 })146 })147}148149func replaceSocialOutsideCode(content string, replace func(string) string) string {150 lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")151 marker := ""152 for i, line := range lines {153 if marker != "" {154 if views.ClosesFence(line, marker) {155 marker = ""156 }157 continue158 }159 if next, _, ok := views.OpenFence(line); ok {160 marker = next161 continue162 }163 lines[i] = replaceSocialInlineCode(line, replace)164 }165 return strings.Join(lines, "\n")166}167168func replaceSocialInlineCode(line string, replace func(string) string) string {169 var out strings.Builder170 for len(line) > 0 {171 start := strings.IndexByte(line, '`')172 if start < 0 {173 out.WriteString(replace(line))174 break175 }176 out.WriteString(replace(line[:start]))177 run := 1178 for run < len(line)-start && line[start+run] == '`' {179 run++180 }181 end := strings.Index(line[start+run:], strings.Repeat("`", run))182 if end < 0 {183 out.WriteString(line[start:])184 break185 }186 end += start + run187 out.WriteString(line[start : end+run])188 line = line[end+run:]189 }190 return out.String()191}192193func replaceStandaloneSocialMedia(content string, items []roomAttachment, tokenFor func(string) string) string {194 known := make(map[string]bool, len(items))195 for _, item := range items {196 known[item.URL] = true197 }198 return replaceSocialOutsideCode(content, func(line string) string {199 trimmed := strings.TrimSpace(line)200 if raw := socialMediaURL(strings.TrimRight(trimmed, ".,;:!?")); raw != "" && raw == trimmed && known[raw] {201 return tokenFor(raw)202 }203 return line204 })205}206207func socialMediaFigure(item roomAttachment) string {208 raw := socialMediaURL(item.URL)209 if raw == "" {210 return ""211 }212 href := template.HTMLEscapeString(raw)213 alt := template.HTMLEscapeString(roomAttachmentLabel(item))214 if alt == "" {215 alt = "Media"216 }217 var media string218 mime := strings.ToLower(item.MIME)219 if strings.HasPrefix(mime, "image/") && mime != "image/svg+xml" {220 media = `<img src="` + href + `" alt="` + alt + `" loading="lazy" referrerpolicy="no-referrer">`221 } else if strings.HasPrefix(mime, "video/") {222 media = `<video controls preload="metadata" playsinline referrerpolicy="no-referrer" src="` + href + `"></video>`223 } else if strings.HasPrefix(mime, "audio/") {224 media = `<audio controls preload="metadata" src="` + href + `"></audio>`225 } else {226 return `<a data-social-media href="` + href + `" rel="noopener noreferrer" fx-ignore>` + html.EscapeString(path.Base(raw)) + `</a>`227 }228 return `<figure data-social-media>` + media + `</figure>`229}230231func socialMediaInline(item roomAttachment) string {232 raw := socialMediaURL(item.URL)233 if raw == "" {234 return ""235 }236 href := template.HTMLEscapeString(raw)237 alt := template.HTMLEscapeString(roomAttachmentLabel(item))238 if alt == "" {239 alt = "Media"240 }241 switch mime := strings.ToLower(item.MIME); {242 case strings.HasPrefix(mime, "image/") && mime != "image/svg+xml":243 return `<img src="` + href + `" alt="` + alt + `" loading="lazy" referrerpolicy="no-referrer">`244 case strings.HasPrefix(mime, "video/"):245 return `<video controls preload="metadata" playsinline referrerpolicy="no-referrer" src="` + href + `"></video>`246 case strings.HasPrefix(mime, "audio/"):247 return `<audio controls preload="metadata" src="` + href + `"></audio>`248 default:249 return `<a data-social-media href="` + href + `" rel="noopener noreferrer" fx-ignore>` + html.EscapeString(path.Base(raw)) + `</a>`250 }251}252253// Media metadata is preferred, while familiar file extensions keep older254// clients' ordinary image and video links useful without a server fetch.255func socialAttachments(row map[string]any) []roomAttachment {256 content := plainString(row["content"])257 items := roomAttachmentsFromTags(roomTags(row))258 filtered := items[:0]259 seen := map[string]bool{}260 for _, item := range items {261 if seen[item.URL] || strings.Contains(content, item.URL) && !roomAttachmentURLVisible(content, item.URL) {262 continue263 }264 if !strings.Contains(item.MIME, "/") {265 item.MIME = socialMIME(item.URL, item.MIME)266 }267 seen[item.URL] = true268 filtered = append(filtered, item)269 }270 items = filtered271 replaceSocialOutsideCode(content, func(text string) string {272 for _, match := range roomAttachmentReference.FindAllStringSubmatch(text, -1) {273 raw := attachmentReferenceURL(match)274 if seen[raw] || socialMediaURL(raw) == "" || len(items) >= 16 {275 continue276 }277 typ := socialMIME(raw, "")278 if typ == "" && strings.HasPrefix(match[0], "![") && plainString(row["kind"]) == "30023" {279 typ = "image/jpeg"280 }281 if typ == "" {282 continue283 }284 seen[raw] = true285 items = append(items, roomAttachment{URL: raw, MIME: typ, Alt: match[1]})286 }287 return text288 })289 return items290}291func socialMIME(raw, short string) string {292 if short == "" {293 u, err := url.Parse(raw)294 if err != nil {295 return ""296 }297 short = strings.TrimPrefix(strings.ToLower(path.Ext(u.Path)), ".")298 }299 return map[string]string{"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp", "avif": "image/avif", "apng": "image/apng", "mp4": "video/mp4", "m4v": "video/mp4", "webm": "video/webm", "mov": "video/quicktime", "mp3": "audio/mpeg", "m4a": "audio/mp4", "wav": "audio/wav", "ogg": "audio/ogg", "opus": "audio/ogg"}[strings.ToLower(short)]300}