Skip to content

Commit

Permalink
Added rich tooltips for 7tv emote links (#155)
Browse files Browse the repository at this point in the history
Co-authored-by: Leon Richardt <leon.richardt@gmail.com>
Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
  • Loading branch information
3 people authored Jul 3, 2021
1 parent 292442b commit 60f6284
Show file tree
Hide file tree
Showing 12 changed files with 477 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Added link preview support for 7tv emote links. (#155)
- Skip lilliput if image is below maxThumbnailSize. (#184)

## 1.2.0
Expand Down
2 changes: 2 additions & 0 deletions internal/resolvers/default/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/Chatterino/api/internal/resolvers/imgur"
"github.com/Chatterino/api/internal/resolvers/livestreamfails"
"github.com/Chatterino/api/internal/resolvers/oembed"
"github.com/Chatterino/api/internal/resolvers/seventv"
"github.com/Chatterino/api/internal/resolvers/supinic"
"github.com/Chatterino/api/internal/resolvers/twitch"
"github.com/Chatterino/api/internal/resolvers/twitter"
Expand Down Expand Up @@ -106,6 +107,7 @@ func New(cfg config.APIConfig) *R {
r.customResolvers = append(r.customResolvers, twitter.New(cfg)...)
r.customResolvers = append(r.customResolvers, wikipedia.New(cfg)...)
r.customResolvers = append(r.customResolvers, youtube.New(cfg)...)
r.customResolvers = append(r.customResolvers, seventv.New(cfg)...)

return r
}
Expand Down
15 changes: 15 additions & 0 deletions internal/resolvers/seventv/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package seventv

import (
"net/url"

"github.com/Chatterino/api/pkg/resolver"
)

func check(url *url.URL) bool {
if match, _ := resolver.MatchesHosts(url, domains); !match {
return false
}

return emotePathRegex.MatchString(url.Path)
}
133 changes: 133 additions & 0 deletions internal/resolvers/seventv/load.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package seventv

import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"strings"
"time"

"github.com/Chatterino/api/pkg/cache"
"github.com/Chatterino/api/pkg/resolver"
"github.com/Chatterino/api/pkg/utils"
)

func load(emoteHash string, r *http.Request) (interface{}, time.Duration, error) {
log.Println("[SevenTV] GET", emoteHash)

queryMap := map[string]interface{}{
"query": `
query fetchEmote($id: String!) {
emote(id: $id) {
visibility
id
name
owner {
id
display_name
}
}
}`,
"variables": map[string]string{
"id": emoteHash,
},
}

queryBytes, err := json.Marshal(queryMap)
if err != nil {
return &resolver.Response{
Status: http.StatusInternalServerError,
Message: "SevenTV API request error" + resolver.CleanResponse(err.Error()),
}, cache.NoSpecialDur, nil
}

// Execute SevenTV API request
resp, err := resolver.RequestPOST(seventvAPIURL, string(queryBytes))
if err != nil {
return &resolver.Response{
Status: http.StatusInternalServerError,
Message: "SevenTV API request error" + resolver.CleanResponse(err.Error()),
}, cache.NoSpecialDur, nil
}
defer resp.Body.Close()

// Read response into a string
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return &resolver.Response{
Status: http.StatusInternalServerError,
Message: "SevenTV API http body read error " + resolver.CleanResponse(err.Error()),
}, cache.NoSpecialDur, nil
}

// Error out if the emote wasn't found or something else went wrong with the request
if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusMultipleChoices {
return emoteNotFoundResponse, cache.NoSpecialDur, nil
}

var jsonResponse EmoteAPIResponse
if err := json.Unmarshal(body, &jsonResponse); err != nil {
return &resolver.Response{
Status: http.StatusInternalServerError,
Message: "SevenTV API unmarshal error " + resolver.CleanResponse(err.Error()),
}, cache.NoSpecialDur, nil
}

// API returns Data.Emote as null if the emote wasn't found
if jsonResponse.Data.Emote == nil {
return emoteNotFoundResponse, cache.NoSpecialDur, nil
}

// Determine type of the emote based on visibility flags
visibility := jsonResponse.Data.Emote.Visibility
var emoteType []string

if utils.HasBits(visibility, EmoteVisibilityGlobal) {
emoteType = append(emoteType, "Global")
}

if utils.HasBits(visibility, EmoteVisibilityPrivate) {
emoteType = append(emoteType, "Private")
}

// Default to Shared emote
if len(emoteType) == 0 {
emoteType = append(emoteType, "Shared")
}

// Build tooltip data from the API response
data := TooltipData{
Code: jsonResponse.Data.Emote.Name,
Type: strings.Join(emoteType, " "),
Uploader: jsonResponse.Data.Emote.Owner.DisplayName,
Unlisted: utils.HasBits(visibility, EmoteVisibilityHidden),
}

// Build a tooltip using the tooltip template (see tooltipTemplate) with the data we massaged above
var tooltip bytes.Buffer
if err := seventvEmoteTemplate.Execute(&tooltip, data); err != nil {
return &resolver.Response{
Status: http.StatusInternalServerError,
Message: "SevenTV emote template error " + resolver.CleanResponse(err.Error()),
}, cache.NoSpecialDur, nil
}

// Success
successTooltip := &resolver.Response{
Status: http.StatusOK,
Tooltip: url.PathEscape(tooltip.String()),
Thumbnail: utils.FormatThumbnailURL(baseURL, r, fmt.Sprintf(thumbnailFormat, emoteHash)),
Link: fmt.Sprintf("https://7tv.app/emotes/%s", emoteHash),
}

// Hide thumbnail for unlisted or hidden emotes pajaS
if data.Unlisted {
successTooltip.Thumbnail = ""
}

return successTooltip, cache.NoSpecialDur, nil
}
136 changes: 136 additions & 0 deletions internal/resolvers/seventv/load_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package seventv

import (
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/Chatterino/api/pkg/resolver"
"github.com/go-chi/chi/v5"

qt "github.com/frankban/quicktest"
)

var (
emotes = map[string]EmoteAPIResponse{}
)

func init() {
emotes["Pajawalk"] = EmoteAPIResponse{
Data: EmoteAPIResponseData{
Emote: &EmoteAPIEmote{
ID: "604281c81ae70f000d47ffd9",
Name: "Pajawalk",
Visibility: EmoteVisibilityPrivate,
Owner: EmoteAPIUser{
ID: "603d10e496832ffa787ca53c",
DisplayName: "durado_",
},
},
},
}

emotes["Hidden"] = EmoteAPIResponse{
Data: EmoteAPIResponseData{
Emote: &EmoteAPIEmote{
ID: "604281c81ae70f000d47ffd9",
Name: "Hidden",
Visibility: EmoteVisibilityPrivate | EmoteVisibilityHidden,
Owner: EmoteAPIUser{
ID: "603d10e496832ffa787ca53c",
DisplayName: "durado_",
},
},
},
}
}

func TestFoo(t *testing.T) {
c := qt.New(t)

r := chi.NewRouter()
r.Post("/v2/gql", func(w http.ResponseWriter, r *http.Request) {
type gqlQuery struct {
Query string `json:"string"`
Variables map[string]string `json:"variables"`
}

var q gqlQuery

xd, _ := ioutil.ReadAll(r.Body)
err := json.Unmarshal(xd, &q)
if err != nil {
panic(err)
}
if response, ok := emotes[q.Variables["id"]]; ok {
b, _ := json.Marshal(&response)

w.Header().Set("Content-Type", "application/json")
w.Write(b)
return
}

// TODO: return 404
})
ts := httptest.NewServer(r)
defer ts.Close()
seventvAPIURL = ts.URL + "/v2/gql"

type tTest struct {
emoteHash string
expectedTooltip string
}

tests := []tTest{
{
emoteHash: "Pajawalk",
expectedTooltip: `<div style="text-align: left;">
<b>Pajawalk</b><br>
<b>Private SevenTV Emote</b><br>
<b>By:</b> durado_
</div>`,
},
{
emoteHash: "Hidden",
expectedTooltip: `<div style="text-align: left;">
<b>Hidden</b><br>
<b>Private SevenTV Emote</b><br>
<b>By:</b> durado_
<li><b><span style="color: red;">UNLISTED</span></b></li>
</div>`,
},
// TODO: Global emote
// TODO: Private emote
// TODO: Combined emote types
// TODO: Default emote type (shared)
// TODO: Thumbnails
// TODO: emote not found (404)
}

request, _ := http.NewRequest(http.MethodPost, "https://7tv.app/test", nil)

for _, test := range tests {
iret, _, err := load(test.emoteHash, request)

c.Assert(err, qt.IsNil)
c.Assert(iret, qt.Not(qt.IsNil))

response := iret.(*resolver.Response)

c.Assert(response, qt.Not(qt.IsNil))

c.Assert(response.Status, qt.Equals, 200)

// TODO: check thumbnail
// c.Assert(response.Thumbnail, qt.Equals, fmt.Sprintf(thumbnailFormat, test.emoteHash))

// TODO: check error
cleanTooltip, unescapeErr := url.PathUnescape(response.Tooltip)
c.Assert(unescapeErr, qt.IsNil)

c.Assert(cleanTooltip, qt.Equals, test.expectedTooltip)
}
}
41 changes: 41 additions & 0 deletions internal/resolvers/seventv/model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package seventv

type EmoteAPIUser struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
}

type EmoteAPIEmote struct {
ID string `json:"id"`
Name string `json:"name"`
Visibility int32 `json:"visibility"`
Owner EmoteAPIUser `json:"owner"`
}

type EmoteAPIResponseData struct {
Emote *EmoteAPIEmote `json:"emote,omitempty"`
}

type EmoteAPIResponse struct {
Data EmoteAPIResponseData `json:"data"`
}

type TooltipData struct {
Code string
Type string
Uploader string

Unlisted bool
}

const (
EmoteVisibilityPrivate int32 = 1 << iota
EmoteVisibilityGlobal
EmoteVisibilityHidden
EmoteVisibilityOverrideBTTV
EmoteVisibilityOverrideFFZ
EmoteVisibilityOverrideTwitchGlobal
EmoteVisibilityOverrideTwitchSubscriber

EmoteVisibilityAll int32 = (1 << iota) - 1
)
Loading

0 comments on commit 60f6284

Please sign in to comment.