Browse Source

Add support for time tags from Discord

Tulir Asokan 3 years ago
parent
commit
668a77e30d
1 changed files with 112 additions and 4 deletions
  1. 112 4
      formatter_tag.go

+ 112 - 4
formatter_tag.go

@@ -18,9 +18,11 @@ package main
 
 
 import (
 import (
 	"fmt"
 	"fmt"
+	"math"
 	"regexp"
 	"regexp"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
+	"time"
 
 
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark/ast"
 	"github.com/yuin/goldmark/ast"
@@ -83,6 +85,41 @@ func (n *astDiscordChannelMention) String() string {
 	return fmt.Sprintf("<#%d>", n.id)
 	return fmt.Sprintf("<#%d>", n.id)
 }
 }
 
 
+type discordTimestampStyle rune
+
+func (dts discordTimestampStyle) Format() string {
+	switch dts {
+	case 't':
+		return "15:04 MST"
+	case 'T':
+		return "15:04:05 MST"
+	case 'd':
+		return "2006-01-02 MST"
+	case 'D':
+		return "2 January 2006 MST"
+	case 'F':
+		return "Monday, 2 January 2006 15:04 MST"
+	case 'f':
+		fallthrough
+	default:
+		return "2 January 2006 15:04 MST"
+	}
+}
+
+type astDiscordTimestamp struct {
+	astDiscordTag
+
+	timestamp int64
+	style     discordTimestampStyle
+}
+
+func (n *astDiscordTimestamp) String() string {
+	if n.style == 'f' {
+		return fmt.Sprintf("<t:%d>", n.timestamp)
+	}
+	return fmt.Sprintf("<t:%d:%c>", n.timestamp, n.style)
+}
+
 type astDiscordCustomEmoji struct {
 type astDiscordCustomEmoji struct {
 	astDiscordTag
 	astDiscordTag
 	name     string
 	name     string
@@ -98,7 +135,8 @@ func (n *astDiscordCustomEmoji) String() string {
 
 
 type discordTagParser struct{}
 type discordTagParser struct{}
 
 
-var discordTagRegex = regexp.MustCompile(`<(a?:\w+:|@[!&]?|#)(\d+)(?::(\d+):(.+?))?>`)
+// Regex to match everything in https://discord.com/developers/docs/reference#message-formatting
+var discordTagRegex = regexp.MustCompile(`<(a?:\w+:|@[!&]?|#|t:)(\d+)(?::([tTdDfFR])|(\d+):(.+?))?>`)
 var defaultDiscordTagParser = &discordTagParser{}
 var defaultDiscordTagParser = &discordTagParser{}
 
 
 func (s *discordTagParser) Trigger() []byte {
 func (s *discordTagParser) Trigger() []byte {
@@ -131,11 +169,23 @@ func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.C
 	case tagName == "#":
 	case tagName == "#":
 		var guildID int64
 		var guildID int64
 		var channelName string
 		var channelName string
-		if len(match[3]) > 0 && len(match[4]) > 0 {
-			guildID, _ = strconv.ParseInt(string(match[3]), 10, 64)
-			channelName = string(match[4])
+		if len(match[4]) > 0 && len(match[5]) > 0 {
+			guildID, _ = strconv.ParseInt(string(match[4]), 10, 64)
+			channelName = string(match[5])
 		}
 		}
 		return &astDiscordChannelMention{astDiscordTag: tag, guildID: guildID, name: channelName}
 		return &astDiscordChannelMention{astDiscordTag: tag, guildID: guildID, name: channelName}
+	case tagName == "t:":
+		var style discordTimestampStyle
+		if len(match[3]) == 0 {
+			style = 'f'
+		} else {
+			style = discordTimestampStyle(match[3][0])
+		}
+		return &astDiscordTimestamp{
+			astDiscordTag: tag,
+			timestamp:     id,
+			style:         style,
+		}
 	case strings.HasPrefix(tagName, ":"):
 	case strings.HasPrefix(tagName, ":"):
 		return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag}
 		return &astDiscordCustomEmoji{name: tagName, astDiscordTag: tag}
 	case strings.HasPrefix(tagName, "a:"):
 	case strings.HasPrefix(tagName, "a:"):
@@ -157,6 +207,51 @@ func (r *discordTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegi
 	reg.Register(astKindDiscordTag, r.renderDiscordMention)
 	reg.Register(astKindDiscordTag, r.renderDiscordMention)
 }
 }
 
 
+func relativeTimeFormat(ts time.Time) string {
+	now := time.Now()
+	if ts.Year() >= 2262 {
+		return "date out of range for relative format"
+	}
+	duration := ts.Sub(now)
+	word := "in %s"
+	if duration < 0 {
+		duration = -duration
+		word = "%s ago"
+	}
+	var count int
+	var unit string
+	switch {
+	case duration < time.Second:
+		count = int(duration.Milliseconds())
+		unit = "millisecond"
+	case duration < time.Minute:
+		count = int(math.Round(duration.Seconds()))
+		unit = "second"
+	case duration < time.Hour:
+		count = int(math.Round(duration.Minutes()))
+		unit = "minute"
+	case duration < 24*time.Hour:
+		count = int(math.Round(duration.Hours()))
+		unit = "hour"
+	case duration < 30*24*time.Hour:
+		count = int(math.Round(duration.Hours() / 24))
+		unit = "day"
+	case duration < 365*24*time.Hour:
+		count = int(math.Round(duration.Hours() / 24 / 30))
+		unit = "month"
+	default:
+		count = int(math.Round(duration.Hours() / 24 / 365))
+		unit = "year"
+	}
+	var diff string
+	if count == 1 {
+		diff = fmt.Sprintf("a %s", unit)
+	} else {
+		diff = fmt.Sprintf("%d %ss", count, unit)
+	}
+	return fmt.Sprintf(word, diff)
+}
+
 func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) {
 func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) {
 	status = ast.WalkContinue
 	status = ast.WalkContinue
 	if !entering {
 	if !entering {
@@ -188,6 +283,19 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
 			_, _ = fmt.Fprintf(w, `<img data-mx-emoticon src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name)
 			_, _ = fmt.Fprintf(w, `<img data-mx-emoticon src="%[1]s" alt="%[2]s" title="%[2]s" height="32"/>`, reactionMXC.String(), node.name)
 			return
 			return
 		}
 		}
+	case *astDiscordTimestamp:
+		ts := time.Unix(node.timestamp, 0).UTC()
+		var formatted string
+		if node.style == 'R' {
+			formatted = relativeTimeFormat(ts)
+		} else {
+			formatted = ts.Format(node.style.Format())
+		}
+		// https://github.com/matrix-org/matrix-spec-proposals/pull/3160
+		const fullDatetimeFormat = "2006-01-02T15:04:05.000-0700"
+		fullRFC := ts.Format(fullDatetimeFormat)
+		fullHumanReadable := ts.Format(discordTimestampStyle('F').Format())
+		_, _ = fmt.Fprintf(w, `<time title="%s" datetime="%s"><strong>%s</strong></time>`, fullHumanReadable, fullRFC, formatted)
 	}
 	}
 	stringifiable, ok := n.(mautrix.Stringifiable)
 	stringifiable, ok := n.(mautrix.Stringifiable)
 	if ok {
 	if ok {