feat: add chat
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Message formatting.
|
||||
//
|
||||
// The composer accepts three inline markers — *bold*, ~strike~, `code` — and
|
||||
// auto-detects links. Rather than store markup, we store CLEAN text plus a list
|
||||
// of spans describing what to decorate. Two reasons: rendering spans can't
|
||||
// inject HTML no matter what a user types, and every client (web today, native
|
||||
// later) applies them natively instead of parsing a markup dialect.
|
||||
//
|
||||
// OFFSETS ARE UTF-16 CODE UNITS, not bytes and not runes. JavaScript strings
|
||||
// are UTF-16, so `body.slice(offset, offset+length)` in the client only lines
|
||||
// up if we count the same way it does. This matters constantly here: Devanāgarī
|
||||
// and IAST are multi-byte, and emoji are surrogate pairs that count as 2.
|
||||
|
||||
// EntityType values understood by the renderer.
|
||||
const (
|
||||
EntityBold = "bold"
|
||||
EntityStrike = "strike"
|
||||
EntityCode = "code"
|
||||
EntityURL = "url"
|
||||
)
|
||||
|
||||
// Entity marks a formatted span of a message body.
|
||||
type Entity struct {
|
||||
Type string `json:"type"`
|
||||
Offset int `json:"offset"` // in UTF-16 code units
|
||||
Length int `json:"length"` // in UTF-16 code units
|
||||
// URL carries the resolved href for EntityURL spans, so "www.x.org" in the
|
||||
// text becomes a usable "https://www.x.org" without the client guessing.
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// markers maps the opening/closing rune to the entity it produces. Code is
|
||||
// handled with the same table but its contents are treated as literal.
|
||||
var markers = map[rune]string{
|
||||
'*': EntityBold,
|
||||
'~': EntityStrike,
|
||||
'`': EntityCode,
|
||||
}
|
||||
|
||||
// urlRe matches http(s) URLs and bare www. hosts. Deliberately conservative:
|
||||
// matching every bare domain turns "e.g" and "Bg 2.13" into links.
|
||||
var urlRe = regexp.MustCompile(`(?i)(?:https?://|www\.)[^\s<>()\[\]{}"']+`)
|
||||
|
||||
// trailing punctuation that is far more likely to be sentence punctuation than
|
||||
// part of the URL.
|
||||
const urlTrailing = `.,;:!?'"`
|
||||
|
||||
// ParseEntities converts raw composer text into clean body text plus the spans
|
||||
// describing its formatting. Markers are removed from the body — what the
|
||||
// reader copies is the text, not the syntax.
|
||||
func ParseEntities(raw string) (string, []Entity) {
|
||||
var b strings.Builder
|
||||
entities, _ := parseInto(&b, raw, 0)
|
||||
body := b.String()
|
||||
|
||||
// Links are detected last, over the finished body, so offsets need no
|
||||
// adjustment. Code spans are skipped: a URL inside `backticks` is being
|
||||
// shown, not linked.
|
||||
entities = append(entities, detectURLs(body, entities)...)
|
||||
sortEntities(entities)
|
||||
return body, entities
|
||||
}
|
||||
|
||||
// parseInto appends the cleaned text of raw to b and returns its entities plus
|
||||
// how many UTF-16 units were written. Returning the written count (rather than
|
||||
// deriving it from the input) keeps offsets correct however many markers a
|
||||
// nested parse strips.
|
||||
func parseInto(b *strings.Builder, raw string, base int) (entities []Entity, written int) {
|
||||
runes := []rune(raw)
|
||||
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
kind, isMarker := markers[r]
|
||||
if !isMarker {
|
||||
b.WriteRune(r)
|
||||
written += utf16Len(r)
|
||||
continue
|
||||
}
|
||||
|
||||
close := findClose(runes, i)
|
||||
if close < 0 {
|
||||
// An unmatched marker is just a character the user typed.
|
||||
b.WriteRune(r)
|
||||
written += utf16Len(r)
|
||||
continue
|
||||
}
|
||||
|
||||
inner := string(runes[i+1 : close])
|
||||
start := base + written
|
||||
|
||||
if kind == EntityCode {
|
||||
// Code is literal: markers inside it are content, not syntax.
|
||||
b.WriteString(inner)
|
||||
written += utf16LenString(inner)
|
||||
} else {
|
||||
// Recurse so *bold with `code` inside* works.
|
||||
nested, n := parseInto(b, inner, start)
|
||||
entities = append(entities, nested...)
|
||||
written += n
|
||||
}
|
||||
|
||||
entities = append(entities, Entity{Type: kind, Offset: start, Length: base + written - start})
|
||||
i = close
|
||||
}
|
||||
return entities, written
|
||||
}
|
||||
|
||||
// findClose returns the index of the marker closing the one at open, or -1.
|
||||
//
|
||||
// The rules mirror what chat users expect: an opener must start a word and be
|
||||
// followed by content, a closer must end one. Without them "2*3*4" and
|
||||
// "a_b_c"-style text would silently turn into formatting.
|
||||
func findClose(runes []rune, open int) int {
|
||||
marker := runes[open]
|
||||
if open > 0 && isWordRune(runes[open-1]) {
|
||||
return -1 // mid-word: not an opener
|
||||
}
|
||||
if open+1 >= len(runes) || unicode.IsSpace(runes[open+1]) || runes[open+1] == marker {
|
||||
return -1 // empty or space-led content
|
||||
}
|
||||
for i := open + 1; i < len(runes); i++ {
|
||||
if runes[i] != marker {
|
||||
continue
|
||||
}
|
||||
if unicode.IsSpace(runes[i-1]) {
|
||||
continue // closer must follow content
|
||||
}
|
||||
if i+1 < len(runes) && isWordRune(runes[i+1]) {
|
||||
continue // mid-word: not a closer
|
||||
}
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// detectURLs finds links in body, skipping anything already inside a code span.
|
||||
func detectURLs(body string, existing []Entity) []Entity {
|
||||
var out []Entity
|
||||
for _, loc := range urlRe.FindAllStringIndex(body, -1) {
|
||||
text := strings.TrimRight(body[loc[0]:loc[1]], urlTrailing)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
offset := utf16LenString(body[:loc[0]])
|
||||
length := utf16LenString(text)
|
||||
if insideCode(existing, offset) {
|
||||
continue
|
||||
}
|
||||
href := text
|
||||
if !strings.Contains(href, "://") {
|
||||
href = "https://" + href
|
||||
}
|
||||
out = append(out, Entity{Type: EntityURL, Offset: offset, Length: length, URL: href})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func insideCode(entities []Entity, offset int) bool {
|
||||
for _, e := range entities {
|
||||
if e.Type == EntityCode && offset >= e.Offset && offset < e.Offset+e.Length {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sortEntities orders by offset, then by longer span first, so a renderer can
|
||||
// open outer decorations before the ones nested inside them.
|
||||
func sortEntities(entities []Entity) {
|
||||
for i := 1; i < len(entities); i++ {
|
||||
for j := i; j > 0; j-- {
|
||||
a, b := entities[j-1], entities[j]
|
||||
if a.Offset < b.Offset || (a.Offset == b.Offset && a.Length >= b.Length) {
|
||||
break
|
||||
}
|
||||
entities[j-1], entities[j] = b, a
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isWordRune(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
// utf16Len returns how many UTF-16 code units a rune occupies: 2 for anything
|
||||
// outside the Basic Multilingual Plane (emoji), 1 otherwise.
|
||||
func utf16Len(r rune) int {
|
||||
if r > 0xFFFF {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func utf16LenString(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
n += utf16Len(r)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
// utf16Slice extracts a span the way a JavaScript client would, so the tests
|
||||
// assert the offsets are usable by the actual renderer rather than merely
|
||||
// self-consistent.
|
||||
func utf16Slice(s string, offset, length int) string {
|
||||
u := utf16.Encode([]rune(s))
|
||||
if offset < 0 || offset+length > len(u) {
|
||||
return "<out of range>"
|
||||
}
|
||||
return string(utf16.Decode(u[offset : offset+length]))
|
||||
}
|
||||
|
||||
func TestParseEntities_Markers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantBody string
|
||||
wantSpan map[string]string // entity type -> the text it should cover
|
||||
}{
|
||||
{"bold", "hello *world*", "hello world", map[string]string{"bold": "world"}},
|
||||
{"strike", "~gone~ now", "gone now", map[string]string{"strike": "gone"}},
|
||||
{"code", "run `go test` please", "run go test please", map[string]string{"code": "go test"}},
|
||||
{"markers stripped from body", "*a* ~b~ `c`", "a b c", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, entities := ParseEntities(tc.raw)
|
||||
if body != tc.wantBody {
|
||||
t.Fatalf("body = %q, want %q", body, tc.wantBody)
|
||||
}
|
||||
for kind, want := range tc.wantSpan {
|
||||
found := false
|
||||
for _, e := range entities {
|
||||
if e.Type == kind {
|
||||
if got := utf16Slice(body, e.Offset, e.Length); got != want {
|
||||
t.Errorf("%s covers %q, want %q", kind, got, want)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no %s entity in %+v", kind, entities)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Offsets are UTF-16 code units because the client slices JS strings with them.
|
||||
// Devanāgarī is multi-byte and emoji are surrogate pairs, so a byte- or
|
||||
// rune-based offset would silently mis-highlight in exactly this app's content.
|
||||
func TestParseEntities_UTF16Offsets(t *testing.T) {
|
||||
tests := []struct{ name, raw, want string }{
|
||||
{"devanagari prefix", "श्री कृष्ण *bold*", "bold"},
|
||||
{"emoji prefix", "🙏🙏 *namaste*", "namaste"},
|
||||
{"emoji inside span", "*🙏 namaste*", "🙏 namaste"},
|
||||
{"iast prefix", "Bhagavad-gītā ~old~", "old"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, entities := ParseEntities(tc.raw)
|
||||
if len(entities) == 0 {
|
||||
t.Fatalf("no entities for %q (body %q)", tc.raw, body)
|
||||
}
|
||||
e := entities[len(entities)-1]
|
||||
if got := utf16Slice(body, e.Offset, e.Length); got != tc.want {
|
||||
t.Errorf("span covers %q, want %q (body %q, entity %+v)", got, tc.want, body, e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Text that merely contains marker characters must survive untouched.
|
||||
func TestParseEntities_NotFormatting(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"2*3*4", // mid-word asterisks
|
||||
"a~b~c", // mid-word tildes
|
||||
"* not bold", // space after opener
|
||||
"unmatched *marker", // no closer
|
||||
"**", // empty
|
||||
"5 * 3 = 15", // spaced operators
|
||||
} {
|
||||
body, entities := ParseEntities(raw)
|
||||
if body != raw {
|
||||
t.Errorf("ParseEntities(%q) rewrote body to %q", raw, body)
|
||||
}
|
||||
for _, e := range entities {
|
||||
if e.Type != EntityURL {
|
||||
t.Errorf("ParseEntities(%q) produced %s entity %+v", raw, e.Type, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntities_Nesting(t *testing.T) {
|
||||
body, entities := ParseEntities("*bold with `code` inside*")
|
||||
const wantBody = "bold with code inside"
|
||||
if body != wantBody {
|
||||
t.Fatalf("body = %q, want %q", body, wantBody)
|
||||
}
|
||||
var bold, code *Entity
|
||||
for i := range entities {
|
||||
switch entities[i].Type {
|
||||
case EntityBold:
|
||||
bold = &entities[i]
|
||||
case EntityCode:
|
||||
code = &entities[i]
|
||||
}
|
||||
}
|
||||
if bold == nil || code == nil {
|
||||
t.Fatalf("want both bold and code, got %+v", entities)
|
||||
}
|
||||
if got := utf16Slice(body, bold.Offset, bold.Length); got != wantBody {
|
||||
t.Errorf("bold covers %q, want the whole body", got)
|
||||
}
|
||||
if got := utf16Slice(body, code.Offset, code.Length); got != "code" {
|
||||
t.Errorf("code covers %q, want %q", got, "code")
|
||||
}
|
||||
// Outer span must sort first so a renderer opens it before the nested one.
|
||||
if entities[0].Type != EntityBold {
|
||||
t.Errorf("entities[0] = %s, want the outer bold span first", entities[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntities_URLs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantText string
|
||||
wantHref string
|
||||
}{
|
||||
{"https", "see https://prabhupadasays.org/bg/2/13 here",
|
||||
"https://prabhupadasays.org/bg/2/13", "https://prabhupadasays.org/bg/2/13"},
|
||||
{"bare www gets a scheme", "visit www.prabhupadasays.org now",
|
||||
"www.prabhupadasays.org", "https://www.prabhupadasays.org"},
|
||||
{"trailing sentence punctuation excluded", "read https://example.org/a.",
|
||||
"https://example.org/a", "https://example.org/a"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, entities := ParseEntities(tc.raw)
|
||||
var url *Entity
|
||||
for i := range entities {
|
||||
if entities[i].Type == EntityURL {
|
||||
url = &entities[i]
|
||||
}
|
||||
}
|
||||
if url == nil {
|
||||
t.Fatalf("no url entity in %+v", entities)
|
||||
}
|
||||
if got := utf16Slice(body, url.Offset, url.Length); got != tc.wantText {
|
||||
t.Errorf("covers %q, want %q", got, tc.wantText)
|
||||
}
|
||||
if url.URL != tc.wantHref {
|
||||
t.Errorf("href = %q, want %q", url.URL, tc.wantHref)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A URL inside backticks is being shown, not linked.
|
||||
func TestParseEntities_URLInsideCodeIsNotLinked(t *testing.T) {
|
||||
_, entities := ParseEntities("run `curl https://example.org` first")
|
||||
for _, e := range entities {
|
||||
if e.Type == EntityURL {
|
||||
t.Errorf("linked a URL inside a code span: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Bg 2.13" and "e.g" must not become links — the conservative pattern is what
|
||||
// keeps scripture references from turning blue.
|
||||
func TestParseEntities_NoFalsePositiveLinks(t *testing.T) {
|
||||
for _, raw := range []string{"Bg 2.13 is the verse", "e.g. this one", "version 1.2.3"} {
|
||||
_, entities := ParseEntities(raw)
|
||||
for _, e := range entities {
|
||||
if e.Type == EntityURL {
|
||||
t.Errorf("ParseEntities(%q) linked %+v", raw, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// entities is stored as jsonb; empty must round-trip as [] rather than null so
|
||||
// the column default and the parser agree.
|
||||
func TestParseEntities_JSONShape(t *testing.T) {
|
||||
_, entities := ParseEntities("plain text")
|
||||
if entities == nil {
|
||||
entities = []Entity{}
|
||||
}
|
||||
b, err := json.Marshal(entities)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != "[]" {
|
||||
t.Errorf("marshalled %s, want []", b)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -71,10 +71,13 @@ type SocialGraphService struct {
|
||||
reports *repository.ReportRepo
|
||||
posts *repository.PostRepo
|
||||
users *repository.UserRepo
|
||||
notif *NotificationService
|
||||
mail mailer.Mailer
|
||||
admins []string
|
||||
logger *zap.Logger
|
||||
// chat answers "may the viewer open a DM with this profile?" — the same
|
||||
// rule OpenDM enforces, so the button and the action can't disagree.
|
||||
chat *repository.ChatRepo
|
||||
notif *NotificationService
|
||||
mail mailer.Mailer
|
||||
admins []string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewSocialGraphService(
|
||||
@@ -83,6 +86,7 @@ func NewSocialGraphService(
|
||||
reports *repository.ReportRepo,
|
||||
posts *repository.PostRepo,
|
||||
users *repository.UserRepo,
|
||||
chat *repository.ChatRepo,
|
||||
notif *NotificationService,
|
||||
mail mailer.Mailer,
|
||||
admins []string,
|
||||
@@ -90,7 +94,8 @@ func NewSocialGraphService(
|
||||
) *SocialGraphService {
|
||||
return &SocialGraphService{
|
||||
follows: follows, blocks: blocks, reports: reports, posts: posts,
|
||||
users: users, notif: notif, mail: mail, admins: admins, logger: logger,
|
||||
users: users, chat: chat, notif: notif, mail: mail, admins: admins,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +299,33 @@ func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetI
|
||||
}
|
||||
out.Blocked = viewerBlocked
|
||||
out.BlockedBy = blockedByTarget
|
||||
|
||||
// A block closes messaging in either direction; otherwise the target's own
|
||||
// "who can message you" rule decides. Asking the chat repository — rather
|
||||
// than reimplementing the rule here — is what keeps the offered button and
|
||||
// the eventual OpenDM in agreement.
|
||||
switch {
|
||||
case viewerID == "" || viewerID == targetID:
|
||||
out.CanMessage = false
|
||||
case viewerBlocked || blockedByTarget:
|
||||
out.CanMessage = false
|
||||
out.MessageRestriction = "blocked"
|
||||
case s.chat == nil:
|
||||
out.CanMessage = true
|
||||
default:
|
||||
allowed, err := s.chat.CanStartConversation(ctx, viewerID, targetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.CanMessage = allowed
|
||||
if !allowed {
|
||||
settings, err := s.chat.Settings(ctx, targetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.MessageRestriction = settings.WhoCanMessage
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user