clone

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

main
tinyrelay / internal / daemon / social_categories.go
social_categories.go 4.4K
1package daemon23import (4 "net/url"5 "path"6 "regexp"7 "strings"89 "github.com/FelineStateMachine/tinyrelay/internal/event"10 "github.com/FelineStateMachine/tinyrelay/internal/podcasts"11 "github.com/FelineStateMachine/tinyrelay/internal/views"12)1314var socialContentURL = regexp.MustCompile(`https?://[^\s)<>\]"']+`)15var socialMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\((https?://[^)\s]+)\)`)1617// socialCategoryMatches applies the categories used by the Social navigation18// to the same root notes and long-form posts as the main feed. Media is read19// from NIP-92 metadata first, with URL extensions retained for older events.20func socialCategoryMatches(row event.Event, category string) bool {21 switch category {22 case "", "all", "feed":23 return true24 case "notes":25 return row.Kind == 126 case "articles", "posts":27 return row.Kind == 3002328 case "photos":29 return socialHasMedia(row, "image")30 case "videos":31 return socialHasMedia(row, "video")32 case "podcasts":33 _, ok := podcasts.ParseEpisode(row)34 return ok35 default:36 return false37 }38}3940func socialHasMedia(row event.Event, family string) bool {41 knownURLs := map[string]bool{}42 for _, tag := range row.Tags {43 if len(tag) < 2 {44 continue45 }46 if tag[0] == "image" {47 if socialHTTPURL(tag[1]) {48 knownURLs[strings.TrimSpace(tag[1])] = true49 if family == "image" {50 return true51 }52 }53 continue54 }55 if tag[0] != "imeta" {56 continue57 }58 var raw, mime string59 for _, field := range tag[1:] {60 if strings.HasPrefix(field, "m ") {61 mime = strings.TrimSpace(strings.TrimPrefix(field, "m "))62 }63 if strings.HasPrefix(field, "url ") {64 raw = strings.TrimSpace(strings.TrimPrefix(field, "url "))65 }66 }67 if socialHTTPURL(raw) {68 knownURLs[raw] = true69 if mediaFamily := socialMediaMIMEFamily(mime); mediaFamily != "" {70 if mediaFamily == family {71 return true72 }73 } else if socialMediaURLFamily(raw) == family {74 return true75 }76 }77 }78 content := socialContentOutsideFences(row.Content)79 if row.Kind == 30023 {80 for _, raw := range socialMarkdownImage.FindAllStringSubmatch(content, -1) {81 if len(raw) > 1 && socialHTTPURL(raw[1]) && !knownURLs[strings.TrimSpace(raw[1])] && family == "image" {82 return true83 }84 }85 }86 for _, raw := range socialContentURL.FindAllString(content, -1) {87 if !knownURLs[strings.TrimSpace(raw)] && socialMediaURLFamily(raw) == family {88 return true89 }90 }91 return false92}9394func socialMediaMIMEFamily(mime string) string {95 mime = strings.ToLower(strings.TrimSpace(strings.SplitN(mime, ";", 2)[0]))96 switch {97 case strings.HasPrefix(mime, "image/") && mime != "image/svg+xml":98 return "image"99 case strings.HasPrefix(mime, "video/"):100 return "video"101 case strings.HasPrefix(mime, "audio/"):102 return "audio"103 default:104 return ""105 }106}107108func socialMediaURLFamily(raw string) string {109 raw = strings.TrimRight(strings.TrimSpace(raw), ".,;:!?")110 parsed, err := url.Parse(raw)111 if err != nil || !socialHTTPURL(raw) || parsed.Hostname() == "" {112 return ""113 }114 switch strings.ToLower(path.Ext(parsed.Path)) {115 case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".apng":116 return "image"117 case ".mp4", ".m4v", ".webm", ".mov":118 return "video"119 case ".mp3", ".m4a", ".wav", ".ogg", ".opus":120 return "audio"121 default:122 return ""123 }124}125126func socialHTTPURL(raw string) bool {127 parsed, err := url.Parse(strings.TrimSpace(raw))128 return err == nil && parsed.User == nil && parsed.Hostname() != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")129}130131func socialContentOutsideFences(content string) string {132 lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")133 var out []string134 marker := ""135 for _, line := range lines {136 if marker != "" {137 if views.ClosesFence(line, marker) {138 marker = ""139 }140 continue141 }142 if next, _, ok := views.OpenFence(line); ok {143 marker = next144 continue145 }146 out = append(out, socialContentOutsideInlineCode(line))147 }148 return strings.Join(out, "\n")149}150151func socialContentOutsideInlineCode(line string) string {152 var out strings.Builder153 for len(line) > 0 {154 start := strings.IndexByte(line, '`')155 if start < 0 {156 out.WriteString(line)157 break158 }159 out.WriteString(line[:start])160 run := 1161 for start+run < len(line) && line[start+run] == '`' {162 run++163 }164 marker := strings.Repeat("`", run)165 end := strings.Index(line[start+run:], marker)166 if end < 0 {167 out.WriteString(line[start:])168 break169 }170 line = line[start+run+end+run:]171 }172 return out.String()173}