Merge branch 'master' of github.com:umputun/remark
This commit is contained in:
@@ -159,6 +159,7 @@ _this is the recommended way to run remark42_
|
||||
| edit-time | EDIT_TIME | `5m` | edit window |
|
||||
| read-age | READONLY_AGE | | read-only age of comments, days |
|
||||
| img-proxy | IMG_PROXY | `false` | enable http->https proxy for images |
|
||||
| emoji | EMOJI | `false` | enable emoji support |
|
||||
| port | REMARK_PORT | `8080` | web server port |
|
||||
| web-root | REMARK_WEB_ROOT | `./web` | web server root directory |
|
||||
| update-limit | UPDATE_LIMIT | `0.5` | updates/sec limit |
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
bolt "github.com/coreos/bbolt"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/kyokomi/emoji"
|
||||
authcache "github.com/patrickmn/go-cache"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
@@ -64,6 +65,7 @@ type ServerCommand struct {
|
||||
WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"`
|
||||
UpdateLimit float64 `long:"update-limit" env:"UPDATE_LIMIT" default:"0.5" description:"updates/sec limit"`
|
||||
RestrictedWords []string `long:"restricted-words" env:"RESTRICTED_WORDS" description:"words prohibited to use in comments" env-delim:","`
|
||||
EnableEmoji bool `long:"emoji" env:"EMOJI" description:"enable emoji"`
|
||||
|
||||
Auth struct {
|
||||
TTL struct {
|
||||
@@ -305,7 +307,11 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) {
|
||||
}
|
||||
|
||||
imgProxy := &proxy.Image{Enabled: s.ImageProxy, RoutePath: "/api/v1/img", RemarkURL: s.RemarkURL}
|
||||
commentFormatter := store.NewCommentFormatter(imgProxy)
|
||||
emojiFmt := store.CommentConverterFunc(func(text string) string { return text })
|
||||
if s.EnableEmoji {
|
||||
emojiFmt = func(text string) string { return emoji.Sprint(text) }
|
||||
}
|
||||
commentFormatter := store.NewCommentFormatter(imgProxy, emojiFmt)
|
||||
|
||||
sslConfig, err := s.makeSSLConfig()
|
||||
if err != nil {
|
||||
@@ -579,8 +585,8 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) {
|
||||
SMTPPassword: s.Auth.Email.SMTPPassword,
|
||||
TimeOut: s.Auth.Email.TimeOut,
|
||||
}
|
||||
s := sender.NewEmailClient(params, log.Default())
|
||||
authenticator.AddVerifProvider("email", msgTemplate, s)
|
||||
sndr := sender.NewEmailClient(params, log.Default())
|
||||
authenticator.AddVerifProvider("email", msgTemplate, sndr)
|
||||
}
|
||||
|
||||
if s.Auth.Anonymous {
|
||||
|
||||
@@ -45,8 +45,8 @@ func TestServerApp(t *testing.T) {
|
||||
client := http.Client{Timeout: 5 * time.Second}
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
require.Nil(t, err)
|
||||
resp, err = client.Do(req)
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
@@ -381,10 +381,10 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
client := http.Client{Timeout: 1 * time.Second}
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/p/2018/12/29/podcast-630/", "site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-JWT", tk)
|
||||
require.Nil(t, err)
|
||||
resp, err := client.Do(req)
|
||||
require.Nil(t, err)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode, "non-blocked user able to post")
|
||||
|
||||
@@ -406,8 +406,8 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
// try add a comment with blocked user
|
||||
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
strings.NewReader(`{"text": "test 123 blah", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-JWT", tk)
|
||||
require.Nil(t, err)
|
||||
resp, err = client.Do(req)
|
||||
require.Nil(t, err)
|
||||
defer resp.Body.Close()
|
||||
@@ -444,11 +444,10 @@ func prepServerApp(t *testing.T, duration time.Duration, fn func(o ServerCommand
|
||||
require.Nil(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(duration)
|
||||
time.AfterFunc(duration, func() {
|
||||
log.Print("[TEST] terminate app")
|
||||
cancel()
|
||||
}()
|
||||
})
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
return app, ctx
|
||||
}
|
||||
|
||||
@@ -40,25 +40,25 @@ func TestNative_Export(t *testing.T) {
|
||||
|
||||
dec := json.NewDecoder(strings.NewReader(c1))
|
||||
|
||||
meta := struct {
|
||||
m := struct {
|
||||
Version int `json:"version"`
|
||||
Users []service.UserMetaData `json:"users"`
|
||||
Posts []service.PostMetaData `json:"posts"`
|
||||
}{}
|
||||
|
||||
require.NoError(t, dec.Decode(&meta), "decode meta")
|
||||
require.NoError(t, dec.Decode(&m), "decode meta")
|
||||
|
||||
assert.Equal(t, 2, len(meta.Users))
|
||||
assert.Equal(t, "user1", meta.Users[0].ID)
|
||||
assert.Equal(t, false, meta.Users[0].Blocked.Status)
|
||||
assert.Equal(t, true, meta.Users[0].Verified)
|
||||
assert.Equal(t, "user2", meta.Users[1].ID)
|
||||
assert.Equal(t, true, meta.Users[1].Blocked.Status)
|
||||
assert.Equal(t, false, meta.Users[1].Verified)
|
||||
assert.Equal(t, 2, len(m.Users))
|
||||
assert.Equal(t, "user1", m.Users[0].ID)
|
||||
assert.Equal(t, false, m.Users[0].Blocked.Status)
|
||||
assert.Equal(t, true, m.Users[0].Verified)
|
||||
assert.Equal(t, "user2", m.Users[1].ID)
|
||||
assert.Equal(t, true, m.Users[1].Blocked.Status)
|
||||
assert.Equal(t, false, m.Users[1].Verified)
|
||||
|
||||
assert.Equal(t, 1, len(meta.Posts))
|
||||
assert.Equal(t, "https://radio-t.com", meta.Posts[0].URL)
|
||||
assert.Equal(t, true, meta.Posts[0].ReadOnly)
|
||||
assert.Equal(t, 1, len(m.Posts))
|
||||
assert.Equal(t, "https://radio-t.com", m.Posts[0].URL)
|
||||
assert.Equal(t, true, m.Posts[0].ReadOnly)
|
||||
|
||||
comments := [3]store.Comment{}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestMigrator_Import(t *testing.T) {
|
||||
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=radio-t&provider=native", r)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
assert.Nil(t, err)
|
||||
resp, err := client.Do(req)
|
||||
@@ -116,6 +117,7 @@ func TestMigrator_ImportDouble(t *testing.T) {
|
||||
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with 10k records
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=radio-t&provider=native", r)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
assert.Nil(t, err)
|
||||
resp, err := client.Do(req)
|
||||
@@ -124,6 +126,7 @@ func TestMigrator_ImportDouble(t *testing.T) {
|
||||
|
||||
client = &http.Client{Timeout: 1 * time.Second}
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=radio-t&provider=native", r)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
assert.Nil(t, err)
|
||||
resp, err = client.Do(req)
|
||||
@@ -144,6 +147,7 @@ func TestMigrator_ImportWaitExpired(t *testing.T) {
|
||||
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with 10k records
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=radio-t&provider=native", r)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
require.Nil(t, err)
|
||||
resp, err := client.Do(req)
|
||||
@@ -152,6 +156,7 @@ func TestMigrator_ImportWaitExpired(t *testing.T) {
|
||||
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/import/wait?site=radio-t&timeout=100ms", nil)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
assert.NoError(t, err)
|
||||
resp, err = client.Do(req)
|
||||
@@ -220,6 +225,7 @@ func TestMigrator_Export(t *testing.T) {
|
||||
func waitForImportCompletion(t *testing.T, ts *httptest.Server) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/import/wait?site=radio-t", nil)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
assert.NoError(t, err)
|
||||
resp, err := client.Do(req)
|
||||
|
||||
@@ -441,14 +441,14 @@ func addFileServer(r chi.Router, path string, root http.FileSystem) {
|
||||
path += "*"
|
||||
|
||||
r.With(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(20, nil)), middleware.Timeout(10*time.Second)).
|
||||
Get(path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Get(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
// don't show dirs, just serve files
|
||||
if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != (origPath+"/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
webFS.ServeHTTP(w, r)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
func encodeJSONWithHTML(v interface{}) ([]byte, error) {
|
||||
|
||||
@@ -297,14 +297,14 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
|
||||
memCache, err := cache.NewMemoryCache()
|
||||
assert.NoError(t, err)
|
||||
|
||||
adminStore := adminstore.NewStaticStore("123456", []string{"a1", "a2"}, "admin@remark-42.com")
|
||||
astore := adminstore.NewStaticStore("123456", []string{"a1", "a2"}, "admin@remark-42.com")
|
||||
restrictedWordsMatcher := service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: []string{"duck"}})
|
||||
|
||||
dataStore := &service.DataStore{
|
||||
Engine: b,
|
||||
EditDuration: 5 * time.Minute,
|
||||
MaxCommentSize: 4000,
|
||||
AdminStore: adminStore,
|
||||
AdminStore: astore,
|
||||
MaxVotes: service.UnlimitedVotes,
|
||||
RestrictedWordsMatcher: restrictedWordsMatcher,
|
||||
}
|
||||
@@ -337,7 +337,7 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
|
||||
NativeImporter: &migrator.Native{DataStore: dataStore},
|
||||
NativeExporter: &migrator.Native{DataStore: dataStore},
|
||||
Cache: &cache.Nop{},
|
||||
KeyStore: adminStore,
|
||||
KeyStore: astore,
|
||||
},
|
||||
Streamer: &Streamer{
|
||||
Refresh: 100 * time.Millisecond,
|
||||
@@ -387,7 +387,7 @@ func get(t *testing.T, url string) (string, int) {
|
||||
return string(body), r.StatusCode
|
||||
}
|
||||
|
||||
func sendReq(t *testing.T, r *http.Request, token string) (*http.Response, error) {
|
||||
func sendReq(_ *testing.T, r *http.Request, token string) (*http.Response, error) {
|
||||
client := http.Client{Timeout: 5 * time.Second}
|
||||
if token != "" {
|
||||
r.Header.Set("X-JWT", token)
|
||||
|
||||
@@ -159,6 +159,8 @@ func (s *rss) toRssFeed(url string, comments []store.Comment, description string
|
||||
parentComment, err := s.dataService.Get(c.Locator, c.ParentID, store.User{})
|
||||
if err == nil {
|
||||
f.Title = fmt.Sprintf("%s > %s", c.User.Name, parentComment.User.Name)
|
||||
f.Description = f.Description + "<p><blockquote><summary>" + parentComment.Snippet(300) +
|
||||
"</summary></blockquote></p>"
|
||||
} else {
|
||||
log.Printf("[WARN] failed to get info about parent comment, %s", err)
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestServer_RssWithReply(t *testing.T) {
|
||||
<item>
|
||||
<title>developer one > developer one</title>
|
||||
<link>https://radio-t.com/blah10#remark42__comment-comment-id-2</link>
|
||||
<description>xyz test</description>
|
||||
<description>xyz test<p><blockquote><summary>test 123</summary></blockquote></p></description>
|
||||
<author>developer one</author>
|
||||
<guid>comment-id-2</guid>
|
||||
<pubDate>%s</pubDate>
|
||||
@@ -247,7 +247,7 @@ func TestServer_RssReplies(t *testing.T) {
|
||||
<item>
|
||||
<title>user3 > user1</title>
|
||||
<link>https://radio-t.com/blah1#remark42__comment-comment-3</link>
|
||||
<description>reply to c1 from user3</description>
|
||||
<description>reply to c1 from user3<p><blockquote><summary>c1</summary></blockquote></p></description>
|
||||
<author>user3</author>
|
||||
<guid>comment-3</guid>
|
||||
<pubDate>%s</pubDate>
|
||||
@@ -255,7 +255,7 @@ func TestServer_RssReplies(t *testing.T) {
|
||||
<item>
|
||||
<title>user2 > user1</title>
|
||||
<link>https://radio-t.com/blah1#remark42__comment-comment-2</link>
|
||||
<description>reply to c1 from user2</description>
|
||||
<description>reply to c1 from user2<p><blockquote><summary>c1</summary></blockquote></p></description>
|
||||
<author>user2</author>
|
||||
<guid>comment-2</guid>
|
||||
<pubDate>%s</pubDate>
|
||||
|
||||
@@ -50,11 +50,13 @@ func TestErrorDetailsMsg(t *testing.T) {
|
||||
func TestErrorDetailsMsgWithUser(t *testing.T) {
|
||||
callerFn := func() {
|
||||
req, err := http.NewRequest("GET", "https://example.com/test?k1=v1&k2=v2", nil)
|
||||
require.NoError(t, err)
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
req = SetUserInfo(req, store.User{Name: "test", ID: "id"})
|
||||
require.Nil(t, err)
|
||||
msg := errDetailsMsg(req, 500, errors.New("error 500"), "error details 123456", 34567)
|
||||
assert.Equal(t, "error details 123456 - error 500 - 500 (34567) - test/id - 127.0.0.1 - https://example.com/test?k1=v1&k2=v2 [caused by app/rest/httperrors_test.go:59 rest.TestErrorDetailsMsgWithUser]", msg)
|
||||
assert.Equal(t, "error details 123456 - error 500 - 500 (34567) - test/id - 127.0.0.1 - https://example." +
|
||||
"com/test?k1=v1&k2=v2 [caused by app/rest/httperrors_test.go:61 rest.TestErrorDetailsMsgWithUser]", msg)
|
||||
}
|
||||
callerFn()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
@@ -66,6 +67,7 @@ const (
|
||||
|
||||
// Maximum length for URL text shortening.
|
||||
const shortURLLen = 48
|
||||
const snippetLen = 200
|
||||
|
||||
// PrepareUntrusted pre-processes a comment received from untrusted source by clearing all
|
||||
// autogen fields and reset everything users not supposed to provide
|
||||
@@ -107,3 +109,24 @@ func (c *Comment) Sanitize() {
|
||||
c.User.Name = template.HTMLEscapeString(c.User.Name)
|
||||
c.User.Picture = p.Sanitize(c.User.Picture)
|
||||
}
|
||||
|
||||
// Snippet from comment's text
|
||||
func (c *Comment) Snippet(limit int) string {
|
||||
if limit <= 0 {
|
||||
limit = snippetLen
|
||||
}
|
||||
cleanText := strings.Replace(c.Text, "\n", " ", -1)
|
||||
size := len([]rune(cleanText))
|
||||
if size < limit {
|
||||
return cleanText
|
||||
}
|
||||
snippet := []rune(cleanText)[:size]
|
||||
// go back in snippet and found the first space
|
||||
for i := len(snippet) - 1; i >= 0; i-- {
|
||||
if snippet[i] == ' ' {
|
||||
snippet = snippet[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
return string(snippet) + " ..."
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -128,3 +129,25 @@ func TestComment_SetDeletedHard(t *testing.T) {
|
||||
assert.False(t, comment.Pin)
|
||||
assert.Equal(t, User{Name: "deleted", ID: "deleted", Picture: "", Admin: false, Blocked: false, IP: ""}, comment.User)
|
||||
}
|
||||
|
||||
func TestComment_Snippet(t *testing.T) {
|
||||
tbl := []struct {
|
||||
limit int
|
||||
inp string
|
||||
out string
|
||||
}{
|
||||
{0, "", ""},
|
||||
{-1, "test\nblah", "test blah"},
|
||||
{5, "test\nblah", "test ..."},
|
||||
{5, "xyz12345 xxx", "xyz12345 ..."},
|
||||
{10, "xyz12345 xxx\ntest 123456", "xyz12345 xxx test ..."},
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
c := Comment{Text: tt.inp}
|
||||
out := c.Snippet(tt.limit)
|
||||
assert.Equal(t, tt.out, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ require (
|
||||
github.com/gorilla/feeds v1.1.1
|
||||
github.com/hashicorp/go-multierror v1.0.0
|
||||
github.com/jessevdk/go-flags v0.0.0-20180331124232-1c38ed7ad0cc
|
||||
github.com/kyokomi/emoji v2.1.0+incompatible
|
||||
github.com/microcosm-cc/bluemonday v1.0.2
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pkg/errors v0.8.1
|
||||
|
||||
@@ -87,6 +87,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kyokomi/emoji v2.1.0+incompatible h1:+DYU2RgpI6OHG4oQkM5KlqD3Wd3UPEsX8jamTo1Mp6o=
|
||||
github.com/kyokomi/emoji v2.1.0+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 h1:Ys0rDzh8s4UMlGaDa1UTA0sfKgvF0hQZzTYX8ktjiDc=
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.idea
|
||||
emoji.iml
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 kyokomi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# Emoji
|
||||
Emoji is a simple golang package.
|
||||
|
||||
[](https://app.wercker.com/project/byKey/7bef60de2c6d3e0e6c13d56b2393c5d8)
|
||||
[](https://coveralls.io/r/kyokomi/emoji?branch=master)
|
||||
[](https://godoc.org/github.com/kyokomi/emoji)
|
||||
|
||||
Get it:
|
||||
|
||||
```
|
||||
go get github.com/kyokomi/emoji
|
||||
```
|
||||
|
||||
Import it:
|
||||
|
||||
```
|
||||
import (
|
||||
"github.com/kyokomi/emoji"
|
||||
)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/kyokomi/emoji"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Hello World Emoji!")
|
||||
|
||||
emoji.Println(":beer: Beer!!!")
|
||||
|
||||
pizzaMessage := emoji.Sprint("I like a :pizza: and :sushi:!!")
|
||||
fmt.Println(pizzaMessage)
|
||||
}
|
||||
```
|
||||
|
||||
## Demo
|
||||
|
||||

|
||||
|
||||
## Reference
|
||||
|
||||
- [GitHub EMOJI CHEAT SHEET](http://www.emoji-cheat-sheet.com/)
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://github.com/kyokomi/emoji/blob/master/LICENSE)
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
// Package emoji terminal output.
|
||||
package emoji
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
//go:generate generateEmojiCodeMap -pkg emoji
|
||||
|
||||
// Replace Padding character for emoji.
|
||||
const (
|
||||
ReplacePadding = " "
|
||||
)
|
||||
|
||||
// CodeMap gets the underlying map of emoji.
|
||||
func CodeMap() map[string]string {
|
||||
return emojiCodeMap
|
||||
}
|
||||
|
||||
// regular expression that matches :flag-[countrycode]:
|
||||
var flagRegexp = regexp.MustCompile(":flag-([a-z]{2}):")
|
||||
|
||||
func emojize(x string) string {
|
||||
str, ok := emojiCodeMap[x]
|
||||
if ok {
|
||||
return str + ReplacePadding
|
||||
}
|
||||
if match := flagRegexp.FindStringSubmatch(x); len(match) == 2 {
|
||||
return regionalIndicator(match[1][0]) + regionalIndicator(match[1][1])
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// regionalIndicator maps a lowercase letter to a unicode regional indicator
|
||||
func regionalIndicator(i byte) string {
|
||||
return string('\U0001F1E6' + rune(i) - 'a')
|
||||
}
|
||||
|
||||
func replaseEmoji(input *bytes.Buffer) string {
|
||||
emoji := bytes.NewBufferString(":")
|
||||
for {
|
||||
i, _, err := input.ReadRune()
|
||||
if err != nil {
|
||||
// not replase
|
||||
return emoji.String()
|
||||
}
|
||||
|
||||
if i == ':' && emoji.Len() == 1 {
|
||||
return emoji.String() + replaseEmoji(input)
|
||||
}
|
||||
|
||||
emoji.WriteRune(i)
|
||||
switch {
|
||||
case unicode.IsSpace(i):
|
||||
return emoji.String()
|
||||
case i == ':':
|
||||
return emojize(emoji.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func compile(x string) string {
|
||||
if x == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
input := bytes.NewBufferString(x)
|
||||
output := bytes.NewBufferString("")
|
||||
|
||||
for {
|
||||
i, _, err := input.ReadRune()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
switch i {
|
||||
default:
|
||||
output.WriteRune(i)
|
||||
case ':':
|
||||
output.WriteString(replaseEmoji(input))
|
||||
}
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func compileValues(a *[]interface{}) {
|
||||
for i, x := range *a {
|
||||
if str, ok := x.(string); ok {
|
||||
(*a)[i] = compile(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print is fmt.Print which supports emoji
|
||||
func Print(a ...interface{}) (int, error) {
|
||||
compileValues(&a)
|
||||
return fmt.Print(a...)
|
||||
}
|
||||
|
||||
// Println is fmt.Println which supports emoji
|
||||
func Println(a ...interface{}) (int, error) {
|
||||
compileValues(&a)
|
||||
return fmt.Println(a...)
|
||||
}
|
||||
|
||||
// Printf is fmt.Printf which supports emoji
|
||||
func Printf(format string, a ...interface{}) (int, error) {
|
||||
format = compile(format)
|
||||
compileValues(&a)
|
||||
return fmt.Printf(format, a...)
|
||||
}
|
||||
|
||||
// Fprint is fmt.Fprint which supports emoji
|
||||
func Fprint(w io.Writer, a ...interface{}) (int, error) {
|
||||
compileValues(&a)
|
||||
return fmt.Fprint(w, a...)
|
||||
}
|
||||
|
||||
// Fprintln is fmt.Fprintln which supports emoji
|
||||
func Fprintln(w io.Writer, a ...interface{}) (int, error) {
|
||||
compileValues(&a)
|
||||
return fmt.Fprintln(w, a...)
|
||||
}
|
||||
|
||||
// Fprintf is fmt.Fprintf which supports emoji
|
||||
func Fprintf(w io.Writer, format string, a ...interface{}) (int, error) {
|
||||
format = compile(format)
|
||||
compileValues(&a)
|
||||
return fmt.Fprintf(w, format, a...)
|
||||
}
|
||||
|
||||
// Sprint is fmt.Sprint which supports emoji
|
||||
func Sprint(a ...interface{}) string {
|
||||
compileValues(&a)
|
||||
return fmt.Sprint(a...)
|
||||
}
|
||||
|
||||
// Sprintf is fmt.Sprintf which supports emoji
|
||||
func Sprintf(format string, a ...interface{}) string {
|
||||
format = compile(format)
|
||||
compileValues(&a)
|
||||
return fmt.Sprintf(format, a...)
|
||||
}
|
||||
|
||||
// Errorf is fmt.Errorf which supports emoji
|
||||
func Errorf(format string, a ...interface{}) error {
|
||||
compileValues(&a)
|
||||
return errors.New(Sprintf(format, a...))
|
||||
}
|
||||
+3939
File diff suppressed because it is too large
Load Diff
+25
@@ -0,0 +1,25 @@
|
||||
box: golang
|
||||
build:
|
||||
steps:
|
||||
- setup-go-workspace
|
||||
- script:
|
||||
name: install goveralls
|
||||
code: |
|
||||
go get github.com/mattn/goveralls
|
||||
- script:
|
||||
name: go get
|
||||
code: |
|
||||
go get ./...
|
||||
- script:
|
||||
name: go build
|
||||
code: |
|
||||
go build ./...
|
||||
- script:
|
||||
name: go test
|
||||
code: |
|
||||
go test ./...
|
||||
- script:
|
||||
name: coveralls
|
||||
code: |
|
||||
goveralls -v -service wercker.com -repotoken $COVERALLS_TOKEN
|
||||
|
||||
Vendored
+2
@@ -68,6 +68,8 @@ github.com/hashicorp/golang-lru
|
||||
github.com/hashicorp/golang-lru/simplelru
|
||||
# github.com/jessevdk/go-flags v0.0.0-20180331124232-1c38ed7ad0cc
|
||||
github.com/jessevdk/go-flags
|
||||
# github.com/kyokomi/emoji v2.1.0+incompatible
|
||||
github.com/kyokomi/emoji
|
||||
# github.com/microcosm-cc/bluemonday v1.0.2
|
||||
github.com/microcosm-cc/bluemonday
|
||||
# github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022
|
||||
|
||||
@@ -47,5 +47,6 @@ services:
|
||||
- NOTIFY_TYPE
|
||||
- NOTIFY_TELEGRAM_TOKEN
|
||||
- NOTIFY_TELEGRAM_CHAN
|
||||
- EMOJI=true
|
||||
volumes:
|
||||
- ./var:/srv/var
|
||||
|
||||
+1
-1
@@ -181,7 +181,7 @@ export class EmailLoginForm extends Component<Props, State> {
|
||||
/>
|
||||
<input
|
||||
className="auth-panel-email-login-form__input"
|
||||
type="text"
|
||||
type="email"
|
||||
placeholder="Email Address"
|
||||
value={this.state.addressValue}
|
||||
onInput={this.onAddressChange}
|
||||
|
||||
@@ -4,6 +4,7 @@ import b from 'bem-react-helper';
|
||||
|
||||
import { Button } from '@app/components/button';
|
||||
import { Theme } from '@app/common/types';
|
||||
import { sleep } from '@app/utils/sleep';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -13,10 +14,13 @@ interface Props {
|
||||
onTitleClick?: () => void;
|
||||
mix?: string;
|
||||
theme: Theme;
|
||||
onOpen?: (root: HTMLDivElement) => unknown;
|
||||
onClose?: (root: HTMLDivElement) => unknown;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isActive: boolean;
|
||||
contentTranslateX: number;
|
||||
}
|
||||
|
||||
export default class Dropdown extends Component<Props, State> {
|
||||
@@ -27,44 +31,124 @@ export default class Dropdown extends Component<Props, State> {
|
||||
|
||||
this.state = {
|
||||
isActive: props.isActive || false,
|
||||
contentTranslateX: 0,
|
||||
};
|
||||
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
this.receiveMessage = this.receiveMessage.bind(this);
|
||||
this.__onOpen = this.__onOpen.bind(this);
|
||||
this.__onClose = this.__onClose.bind(this);
|
||||
}
|
||||
|
||||
onTitleClick() {
|
||||
this.setState({
|
||||
isActive: !this.state.isActive,
|
||||
});
|
||||
const isActive = !this.state.isActive;
|
||||
const contentTranslateX = isActive ? this.state.contentTranslateX : 0;
|
||||
this.setState(
|
||||
{
|
||||
contentTranslateX,
|
||||
isActive,
|
||||
},
|
||||
async () => {
|
||||
await this.__adjustDropDownContent();
|
||||
if (isActive) {
|
||||
this.__onOpen();
|
||||
this.props.onOpen && this.props.onOpen(this.rootNode!);
|
||||
} else {
|
||||
this.__onClose();
|
||||
this.props.onClose && this.props.onClose(this.rootNode!);
|
||||
}
|
||||
|
||||
if (this.props.onTitleClick) {
|
||||
this.props.onTitleClick();
|
||||
if (this.props.onTitleClick) {
|
||||
this.props.onTitleClick();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
storedDocumentHeight: string | null = null;
|
||||
checkInterval: number | undefined = undefined;
|
||||
|
||||
__onOpen() {
|
||||
let firstPass = false;
|
||||
let prevDcBottom: number | null = null;
|
||||
this.checkInterval = window.setInterval(() => {
|
||||
if (!this.rootNode || !this.state.isActive) return;
|
||||
const windowHeight = window.innerHeight;
|
||||
const dcBottom = (() => {
|
||||
const dc = this.rootNode.querySelector('.dropdown__content');
|
||||
if (!dc) return 0;
|
||||
const rect = dc.getBoundingClientRect();
|
||||
return Math.abs(rect.top) + rect.height;
|
||||
})();
|
||||
if (prevDcBottom === null && dcBottom <= windowHeight) return;
|
||||
if (!firstPass) {
|
||||
firstPass = true;
|
||||
this.storedDocumentHeight = document.body.style.minHeight;
|
||||
}
|
||||
if (dcBottom !== prevDcBottom) {
|
||||
prevDcBottom = dcBottom;
|
||||
document.body.style.minHeight = dcBottom + 'px';
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
__onClose() {
|
||||
window.clearInterval(this.checkInterval);
|
||||
document.body.style.minHeight = this.storedDocumentHeight;
|
||||
}
|
||||
|
||||
async __adjustDropDownContent() {
|
||||
if (!this.rootNode) return;
|
||||
const dc = this.rootNode.querySelector<HTMLDivElement>('.dropdown__content');
|
||||
if (!dc) return;
|
||||
await sleep(10);
|
||||
const rect = dc.getBoundingClientRect();
|
||||
if (rect.left > 0) {
|
||||
const wWindow = window.innerWidth;
|
||||
if (rect.right <= wWindow) return;
|
||||
const delta = rect.right - wWindow;
|
||||
const max = Math.min(rect.left, delta);
|
||||
this.setState({
|
||||
contentTranslateX: -max,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
contentTranslateX: -rect.left,
|
||||
});
|
||||
}
|
||||
|
||||
receiveMessage(e: { data: string | object }) {
|
||||
try {
|
||||
const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
|
||||
|
||||
if (data.clickOutside) {
|
||||
if (this.state.isActive) {
|
||||
this.setState({
|
||||
isActive: false,
|
||||
});
|
||||
if (!data.clickOutside) return;
|
||||
if (!this.state.isActive) return;
|
||||
this.setState(
|
||||
{
|
||||
contentTranslateX: 0,
|
||||
isActive: false,
|
||||
},
|
||||
() => {
|
||||
this.__onClose();
|
||||
this.props.onClose && this.props.onClose(this.rootNode!);
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
onOutsideClick(e: MouseEvent) {
|
||||
if (this.rootNode && !this.rootNode.contains(e.target as Node)) {
|
||||
if (this.state.isActive) {
|
||||
this.setState({
|
||||
isActive: false,
|
||||
});
|
||||
if (!this.rootNode || this.rootNode.contains(e.target as Node) || !this.state.isActive) return;
|
||||
this.setState(
|
||||
{
|
||||
contentTranslateX: 0,
|
||||
isActive: false,
|
||||
},
|
||||
() => {
|
||||
this.__onClose();
|
||||
this.props.onClose && this.props.onClose(this.rootNode!);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -96,7 +180,12 @@ export default class Dropdown extends Component<Props, State> {
|
||||
{title}
|
||||
</Button>
|
||||
|
||||
<div className="dropdown__content" tabIndex={-1} role="listbox">
|
||||
<div
|
||||
className="dropdown__content"
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
style={{ transform: `translateX(${this.state.contentTranslateX}px)` }}
|
||||
>
|
||||
{heading && <div className="dropdown__heading">{heading}</div>}
|
||||
<div className="dropdown__items">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@ interface State {
|
||||
|
||||
const Labels = {
|
||||
main: 'Send',
|
||||
edit: 'Edit',
|
||||
edit: 'Save',
|
||||
reply: 'Reply',
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user