Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added rich tooltips for 7tv emote links #155

Merged
merged 26 commits into from
Jul 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9b1e886
Initial work, added basic 7tv emote resolver support
zneix May 31, 2021
558f08e
Added changelog entry
zneix May 31, 2021
8c61749
Fixed crash on invalid emote links, better query
zneix Jun 5, 2021
bb23275
Implement hiding thumbnails for links of unlisted emotes
zneix Jun 5, 2021
542fa52
Handle emote Type properly
zneix Jun 6, 2021
4704209
Merge branch 'master' into zneix/feature/add-7tv-emote-resolver
zneix Jun 6, 2021
bb0dd7c
Add a clean util for bitwise operations
zneix Jun 6, 2021
dc67aab
Add documentation for newly added util
zneix Jun 6, 2021
05a00bd
Update pkg/resolver/request.go
pajlada Jun 6, 2021
6d09a84
Add tests for utils.HasBits
pajlada Jun 6, 2021
568cd0a
Use GQL variables instead of string formatting for building query
pajlada Jun 6, 2021
bcbb92e
Remove anonymous structs from 7tv model for easier testing
pajlada Jun 6, 2021
2df29e6
Split template up onto separate lines for nicer testing
pajlada Jun 6, 2021
6e4dcda
Add basic tests
pajlada Jun 6, 2021
86728c4
add todos for more tests that could be added
pajlada Jun 6, 2021
20ae1c1
Split host and path matching
pajlada Jun 6, 2021
d87cd45
Remove debug printing
pajlada Jun 6, 2021
2e92187
Update changelog entry
pajlada Jun 6, 2021
3391e52
Remove old gql query variable
zneix Jun 6, 2021
ddce648
Merge remote-tracking branch 'origin/master' into zneix/feature/add-7…
zneix Jun 9, 2021
cccc178
Merge branch 'master' into zneix/feature/add-7tv-emote-resolver
zneix Jun 21, 2021
94e4ece
Proxy thumbnail requests... not really
zneix Jun 21, 2021
714fcb0
Fixed tests not working by 'mocking' an HTTP request
pajlada Jul 3, 2021
15f57a3
Remove merge sign
pajlada Jul 3, 2021
e9f2e97
Merge branch 'master' into zneix/feature/add-7tv-emote-resolver
pajlada Jul 3, 2021
36f1ed3
Merge branch 'master' into zneix/feature/add-7tv-emote-resolver
pajlada Jul 3, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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