Merge pull request #143 from ssb-ngi-pointer/language-picking

Add language picker
This commit is contained in:
Alexander Cobleigh 2021-04-20 11:51:51 +02:00 committed by GitHub
commit 2007c73295
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 4973 additions and 158 deletions

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@
"ssb-keys": "^8.1.0",
"ssb-replicate": "^1.3.2",
"ssb-room": "^1.3.0",
"ssb-room-client": "^0.4.0",
"ssb-room-client": "^0.10.0",
"tap-spec": "^5.0.0",
"tape": "^5.2.2"
}

View File

@ -20,6 +20,8 @@ import (
type RoomConfig interface {
GetPrivacyMode(context.Context) (PrivacyMode, error)
SetPrivacyMode(context.Context, PrivacyMode) error
GetDefaultLanguage(context.Context) (string, error)
SetDefaultLanguage(context.Context, string) error
}
// AuthFallbackService allows password authentication which might be helpful for scenarios

View File

@ -9,6 +9,19 @@ import (
)
type FakeRoomConfig struct {
GetDefaultLanguageStub func(context.Context) (string, error)
getDefaultLanguageMutex sync.RWMutex
getDefaultLanguageArgsForCall []struct {
arg1 context.Context
}
getDefaultLanguageReturns struct {
result1 string
result2 error
}
getDefaultLanguageReturnsOnCall map[int]struct {
result1 string
result2 error
}
GetPrivacyModeStub func(context.Context) (roomdb.PrivacyMode, error)
getPrivacyModeMutex sync.RWMutex
getPrivacyModeArgsForCall []struct {
@ -22,6 +35,18 @@ type FakeRoomConfig struct {
result1 roomdb.PrivacyMode
result2 error
}
SetDefaultLanguageStub func(context.Context, string) error
setDefaultLanguageMutex sync.RWMutex
setDefaultLanguageArgsForCall []struct {
arg1 context.Context
arg2 string
}
setDefaultLanguageReturns struct {
result1 error
}
setDefaultLanguageReturnsOnCall map[int]struct {
result1 error
}
SetPrivacyModeStub func(context.Context, roomdb.PrivacyMode) error
setPrivacyModeMutex sync.RWMutex
setPrivacyModeArgsForCall []struct {
@ -38,6 +63,70 @@ type FakeRoomConfig struct {
invocationsMutex sync.RWMutex
}
func (fake *FakeRoomConfig) GetDefaultLanguage(arg1 context.Context) (string, error) {
fake.getDefaultLanguageMutex.Lock()
ret, specificReturn := fake.getDefaultLanguageReturnsOnCall[len(fake.getDefaultLanguageArgsForCall)]
fake.getDefaultLanguageArgsForCall = append(fake.getDefaultLanguageArgsForCall, struct {
arg1 context.Context
}{arg1})
stub := fake.GetDefaultLanguageStub
fakeReturns := fake.getDefaultLanguageReturns
fake.recordInvocation("GetDefaultLanguage", []interface{}{arg1})
fake.getDefaultLanguageMutex.Unlock()
if stub != nil {
return stub(arg1)
}
if specificReturn {
return ret.result1, ret.result2
}
return fakeReturns.result1, fakeReturns.result2
}
func (fake *FakeRoomConfig) GetDefaultLanguageCallCount() int {
fake.getDefaultLanguageMutex.RLock()
defer fake.getDefaultLanguageMutex.RUnlock()
return len(fake.getDefaultLanguageArgsForCall)
}
func (fake *FakeRoomConfig) GetDefaultLanguageCalls(stub func(context.Context) (string, error)) {
fake.getDefaultLanguageMutex.Lock()
defer fake.getDefaultLanguageMutex.Unlock()
fake.GetDefaultLanguageStub = stub
}
func (fake *FakeRoomConfig) GetDefaultLanguageArgsForCall(i int) context.Context {
fake.getDefaultLanguageMutex.RLock()
defer fake.getDefaultLanguageMutex.RUnlock()
argsForCall := fake.getDefaultLanguageArgsForCall[i]
return argsForCall.arg1
}
func (fake *FakeRoomConfig) GetDefaultLanguageReturns(result1 string, result2 error) {
fake.getDefaultLanguageMutex.Lock()
defer fake.getDefaultLanguageMutex.Unlock()
fake.GetDefaultLanguageStub = nil
fake.getDefaultLanguageReturns = struct {
result1 string
result2 error
}{result1, result2}
}
func (fake *FakeRoomConfig) GetDefaultLanguageReturnsOnCall(i int, result1 string, result2 error) {
fake.getDefaultLanguageMutex.Lock()
defer fake.getDefaultLanguageMutex.Unlock()
fake.GetDefaultLanguageStub = nil
if fake.getDefaultLanguageReturnsOnCall == nil {
fake.getDefaultLanguageReturnsOnCall = make(map[int]struct {
result1 string
result2 error
})
}
fake.getDefaultLanguageReturnsOnCall[i] = struct {
result1 string
result2 error
}{result1, result2}
}
func (fake *FakeRoomConfig) GetPrivacyMode(arg1 context.Context) (roomdb.PrivacyMode, error) {
fake.getPrivacyModeMutex.Lock()
ret, specificReturn := fake.getPrivacyModeReturnsOnCall[len(fake.getPrivacyModeArgsForCall)]
@ -102,6 +191,68 @@ func (fake *FakeRoomConfig) GetPrivacyModeReturnsOnCall(i int, result1 roomdb.Pr
}{result1, result2}
}
func (fake *FakeRoomConfig) SetDefaultLanguage(arg1 context.Context, arg2 string) error {
fake.setDefaultLanguageMutex.Lock()
ret, specificReturn := fake.setDefaultLanguageReturnsOnCall[len(fake.setDefaultLanguageArgsForCall)]
fake.setDefaultLanguageArgsForCall = append(fake.setDefaultLanguageArgsForCall, struct {
arg1 context.Context
arg2 string
}{arg1, arg2})
stub := fake.SetDefaultLanguageStub
fakeReturns := fake.setDefaultLanguageReturns
fake.recordInvocation("SetDefaultLanguage", []interface{}{arg1, arg2})
fake.setDefaultLanguageMutex.Unlock()
if stub != nil {
return stub(arg1, arg2)
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeRoomConfig) SetDefaultLanguageCallCount() int {
fake.setDefaultLanguageMutex.RLock()
defer fake.setDefaultLanguageMutex.RUnlock()
return len(fake.setDefaultLanguageArgsForCall)
}
func (fake *FakeRoomConfig) SetDefaultLanguageCalls(stub func(context.Context, string) error) {
fake.setDefaultLanguageMutex.Lock()
defer fake.setDefaultLanguageMutex.Unlock()
fake.SetDefaultLanguageStub = stub
}
func (fake *FakeRoomConfig) SetDefaultLanguageArgsForCall(i int) (context.Context, string) {
fake.setDefaultLanguageMutex.RLock()
defer fake.setDefaultLanguageMutex.RUnlock()
argsForCall := fake.setDefaultLanguageArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeRoomConfig) SetDefaultLanguageReturns(result1 error) {
fake.setDefaultLanguageMutex.Lock()
defer fake.setDefaultLanguageMutex.Unlock()
fake.SetDefaultLanguageStub = nil
fake.setDefaultLanguageReturns = struct {
result1 error
}{result1}
}
func (fake *FakeRoomConfig) SetDefaultLanguageReturnsOnCall(i int, result1 error) {
fake.setDefaultLanguageMutex.Lock()
defer fake.setDefaultLanguageMutex.Unlock()
fake.SetDefaultLanguageStub = nil
if fake.setDefaultLanguageReturnsOnCall == nil {
fake.setDefaultLanguageReturnsOnCall = make(map[int]struct {
result1 error
})
}
fake.setDefaultLanguageReturnsOnCall[i] = struct {
result1 error
}{result1}
}
func (fake *FakeRoomConfig) SetPrivacyMode(arg1 context.Context, arg2 roomdb.PrivacyMode) error {
fake.setPrivacyModeMutex.Lock()
ret, specificReturn := fake.setPrivacyModeReturnsOnCall[len(fake.setPrivacyModeArgsForCall)]
@ -167,8 +318,12 @@ func (fake *FakeRoomConfig) SetPrivacyModeReturnsOnCall(i int, result1 error) {
func (fake *FakeRoomConfig) Invocations() map[string][][]interface{} {
fake.invocationsMutex.RLock()
defer fake.invocationsMutex.RUnlock()
fake.getDefaultLanguageMutex.RLock()
defer fake.getDefaultLanguageMutex.RUnlock()
fake.getPrivacyModeMutex.RLock()
defer fake.getPrivacyModeMutex.RUnlock()
fake.setDefaultLanguageMutex.RLock()
defer fake.setDefaultLanguageMutex.RUnlock()
fake.setPrivacyModeMutex.RLock()
defer fake.setPrivacyModeMutex.RUnlock()
copiedInvocations := map[string][][]interface{}{}

View File

@ -1,8 +1,10 @@
-- +migrate Up
-- the configuration settings for this room, currently only privacy modes
-- the configuration settings for this room, currently privacy mode settings and the default translation for the room
CREATE TABLE config (
id integer NOT NULL PRIMARY KEY,
privacyMode integer NOT NULL, -- open, community, restricted
defaultLanguage TEXT NOT NULL, -- a language tag, e.g. en, sv, de
use_subdomain_for_aliases boolean NOT NULL, -- flag to toggle using subdomains (rather than alias routes) for aliases
CHECK (id == 0) -- should only ever store one row
);
@ -10,9 +12,11 @@ CREATE TABLE config (
-- the config table will only ever contain one row: the rooms current settings
-- we update that row whenever the config changes.
-- to have something to update, we insert the first and only row at id 0
INSERT INTO config (id, privacyMode) VALUES (
0, -- the constant id we will query
2 -- community is the default mode unless overridden
INSERT INTO config (id, privacyMode, defaultLanguage, use_subdomain_for_aliases) VALUES (
0, -- the constant id we will query
2, -- community is the default mode unless overridden
"en", -- english is the default language for all installs
1 -- use subdomain for aliases
);
-- +migrate Down

View File

@ -23,19 +23,25 @@ import (
// Config is an object representing the database table.
type Config struct {
ID int64 `boil:"id" json:"id" toml:"id" yaml:"id"`
PrivacyMode roomdb.PrivacyMode `boil:"privacyMode" json:"privacyMode" toml:"privacyMode" yaml:"privacyMode"`
ID int64 `boil:"id" json:"id" toml:"id" yaml:"id"`
PrivacyMode roomdb.PrivacyMode `boil:"privacyMode" json:"privacyMode" toml:"privacyMode" yaml:"privacyMode"`
DefaultLanguage string `boil:"defaultLanguage" json:"defaultLanguage" toml:"defaultLanguage" yaml:"defaultLanguage"`
UseSubdomainForAliases bool `boil:"use_subdomain_for_aliases" json:"use_subdomain_for_aliases" toml:"use_subdomain_for_aliases" yaml:"use_subdomain_for_aliases"`
R *configR `boil:"-" json:"-" toml:"-" yaml:"-"`
L configL `boil:"-" json:"-" toml:"-" yaml:"-"`
}
var ConfigColumns = struct {
ID string
PrivacyMode string
ID string
PrivacyMode string
DefaultLanguage string
UseSubdomainForAliases string
}{
ID: "id",
PrivacyMode: "privacyMode",
ID: "id",
PrivacyMode: "privacyMode",
DefaultLanguage: "defaultLanguage",
UseSubdomainForAliases: "use_subdomain_for_aliases",
}
// Generated where
@ -61,12 +67,25 @@ func (w whereHelperroomdb_PrivacyMode) GTE(x roomdb.PrivacyMode) qm.QueryMod {
return qmhelper.Where(w.field, qmhelper.GTE, x)
}
type whereHelperbool struct{ field string }
func (w whereHelperbool) EQ(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.EQ, x) }
func (w whereHelperbool) NEQ(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.NEQ, x) }
func (w whereHelperbool) LT(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LT, x) }
func (w whereHelperbool) LTE(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LTE, x) }
func (w whereHelperbool) GT(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GT, x) }
func (w whereHelperbool) GTE(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) }
var ConfigWhere = struct {
ID whereHelperint64
PrivacyMode whereHelperroomdb_PrivacyMode
ID whereHelperint64
PrivacyMode whereHelperroomdb_PrivacyMode
DefaultLanguage whereHelperstring
UseSubdomainForAliases whereHelperbool
}{
ID: whereHelperint64{field: "\"config\".\"id\""},
PrivacyMode: whereHelperroomdb_PrivacyMode{field: "\"config\".\"privacyMode\""},
ID: whereHelperint64{field: "\"config\".\"id\""},
PrivacyMode: whereHelperroomdb_PrivacyMode{field: "\"config\".\"privacyMode\""},
DefaultLanguage: whereHelperstring{field: "\"config\".\"defaultLanguage\""},
UseSubdomainForAliases: whereHelperbool{field: "\"config\".\"use_subdomain_for_aliases\""},
}
// ConfigRels is where relationship names are stored.
@ -86,8 +105,8 @@ func (*configR) NewStruct() *configR {
type configL struct{}
var (
configAllColumns = []string{"id", "privacyMode"}
configColumnsWithoutDefault = []string{"privacyMode"}
configAllColumns = []string{"id", "privacyMode", "defaultLanguage", "use_subdomain_for_aliases"}
configColumnsWithoutDefault = []string{"privacyMode", "defaultLanguage", "use_subdomain_for_aliases"}
configColumnsWithDefault = []string{"id"}
configPrimaryKeyColumns = []string{"id"}
)

View File

@ -48,15 +48,6 @@ var InviteColumns = struct {
// Generated where
type whereHelperbool struct{ field string }
func (w whereHelperbool) EQ(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.EQ, x) }
func (w whereHelperbool) NEQ(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.NEQ, x) }
func (w whereHelperbool) LT(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LT, x) }
func (w whereHelperbool) LTE(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LTE, x) }
func (w whereHelperbool) GT(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GT, x) }
func (w whereHelperbool) GTE(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) }
var InviteWhere = struct {
ID whereHelperint64
HashedToken whereHelperstring

View File

@ -40,7 +40,6 @@ func (c Config) SetPrivacyMode(ctx context.Context, pm roomdb.PrivacyMode) error
return err
}
// cblgh: a walkthrough of this step (again, now that i have some actual context) would be real good :)
err = transact(c.db, func(tx *sql.Tx) error {
// get the settings row
config, err := models.FindConfig(ctx, tx, configRowID)
@ -68,3 +67,45 @@ func (c Config) SetPrivacyMode(ctx context.Context, pm roomdb.PrivacyMode) error
return nil // alles gut!!
}
func (c Config) GetDefaultLanguage(ctx context.Context) (string, error) {
config, err := models.FindConfig(ctx, c.db, configRowID)
if err != nil {
return "", err
}
return config.DefaultLanguage, nil
}
func (c Config) SetDefaultLanguage(ctx context.Context, langTag string) error {
if len(langTag) == 0 {
return fmt.Errorf("language tag cannot be empty")
}
err := transact(c.db, func(tx *sql.Tx) error {
// get the settings row
config, err := models.FindConfig(ctx, tx, configRowID)
if err != nil {
return err
}
// set the new language tag
config.DefaultLanguage = langTag
// issue update stmt
rowsAffected, err := config.Update(ctx, tx, boil.Infer())
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("setting default language should have update the settings row, instead 0 rows were updated")
}
return nil
})
if err != nil {
return err
}
return nil // alles gut!!
}

View File

@ -21,6 +21,7 @@ import (
"github.com/ssb-ngi-pointer/go-ssb-room/roomstate"
"github.com/ssb-ngi-pointer/go-ssb-room/web"
weberrors "github.com/ssb-ngi-pointer/go-ssb-room/web/errors"
"github.com/ssb-ngi-pointer/go-ssb-room/web/i18n"
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
)
@ -65,6 +66,7 @@ func Handler(
r *render.Renderer,
roomState *roomstate.Manager,
fh *weberrors.FlashHelper,
locHelper *i18n.Helper,
dbs Databases,
) http.Handler {
mux := &http.ServeMux{}
@ -84,11 +86,12 @@ func Handler(
var sh = settingsHandler{
r: r,
urlTo: urlTo,
db: dbs.Config,
db: dbs.Config,
loc: locHelper,
}
mux.HandleFunc("/settings", r.HTML("admin/settings.tmpl", sh.overview))
mux.HandleFunc("/settings/set-privacy", sh.setPrivacy)
mux.HandleFunc("/settings/set-language", sh.setLanguage)
mux.HandleFunc("/menu", r.HTML("admin/menu.tmpl", func(w http.ResponseWriter, req *http.Request) (interface{}, error) {
return map[string]interface{}{}, nil

View File

@ -0,0 +1,44 @@
package admin
import (
"net/http"
"strings"
"testing"
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
"github.com/stretchr/testify/assert"
)
/* can't test English atm due to web/i18n/i18ntesting/testing.go:justTheKeys, which generates translations that are just
* translationLabel = "translationLabel"
*/
// func TestLanguageSetDefaultLanguageEnglish(t *testing.T) {
// ts := newSession(t)
// a := assert.New(t)
//
// ts.ConfigDB.GetDefaultLanguageReturns("en", nil)
//
// u := ts.URLTo(router.AdminSettings)
// html, resp := ts.Client.GetHTML(u)
// a.Equal(http.StatusOK, resp.Code, "Wrong HTTP status code")
//
// fmt.Println(html.Html())
// summaryElement := html.Find("#language-summary")
// summaryText := strings.TrimSpace(summaryElement.Text())
// a.Equal("English", summaryText, "summary language should display english translation of language name")
// }
func TestLanguageSetDefaultLanguage(t *testing.T) {
ts := newSession(t)
a := assert.New(t)
ts.ConfigDB.GetDefaultLanguageReturns("de", nil)
u := ts.URLTo(router.AdminSettings)
html, resp := ts.Client.GetHTML(u)
a.Equal(http.StatusOK, resp.Code, "Wrong HTTP status code")
summaryElement := html.Find("#language-summary")
summaryText := strings.TrimSpace(summaryElement.Text())
a.Equal("Deutsch", summaryText, "summary language should display german translation of language name")
}

View File

@ -13,6 +13,7 @@ import (
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/web"
weberrors "github.com/ssb-ngi-pointer/go-ssb-room/web/errors"
"github.com/ssb-ngi-pointer/go-ssb-room/web/i18n"
"github.com/ssb-ngi-pointer/go-ssb-room/web/members"
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
)
@ -20,49 +21,60 @@ import (
type settingsHandler struct {
r *render.Renderer
urlTo web.URLMaker
db roomdb.RoomConfig
db roomdb.RoomConfig
loc *i18n.Helper
}
func (h settingsHandler) overview(w http.ResponseWriter, req *http.Request) (interface{}, error) {
privacyModes := []roomdb.PrivacyMode{roomdb.ModeOpen, roomdb.ModeCommunity, roomdb.ModeRestricted}
currentMode, err := h.db.GetPrivacyMode(req.Context())
if err != nil {
return nil, fmt.Errorf("failed to retrieve current privacy mode: %w", err)
}
currentLanguage, err := h.db.GetDefaultLanguage(req.Context())
if err != nil {
return nil, fmt.Errorf("failed to retrieve current privacy mode: %w", err)
}
return map[string]interface{}{
"CurrentMode": currentMode,
"PrivacyModes": privacyModes,
csrf.TemplateTag: csrf.TemplateField(req),
"CurrentMode": currentMode,
"CurrentLanguage": h.loc.ChooseTranslation(currentLanguage),
"PrivacyModes": privacyModes,
csrf.TemplateTag: csrf.TemplateField(req),
}, nil
}
func (h settingsHandler) setPrivacy(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
// TODO: proper error type
h.r.Error(w, req, http.StatusBadRequest, fmt.Errorf("bad request"))
func (h settingsHandler) setLanguage(w http.ResponseWriter, req *http.Request) {
if !h.verifyPostRequirements(w, req) {
return
}
if err := req.ParseForm(); err != nil {
// TODO: proper error type
h.r.Error(w, req, http.StatusBadRequest, fmt.Errorf("bad request: %w", err))
return
}
// get the member behind the POST
currentMember := members.FromContext(req.Context())
// handles error cases & make sures the member is an admin
currentMember := h.getMember(w, req)
if currentMember == nil {
err := weberrors.ErrForbidden{Details: fmt.Errorf("not a registered member")}
h.r.Error(w, req, http.StatusInternalServerError, err)
return
}
// make sure the member is an admin
if currentMember.Role != roomdb.RoleAdmin {
err := weberrors.ErrForbidden{Details: fmt.Errorf("yr not an admin! naughty naughty")}
h.r.Error(w, req, http.StatusInternalServerError, err)
langTag := req.Form.Get("lang")
err := h.db.SetDefaultLanguage(req.Context(), langTag)
if err != nil {
h.r.Error(w, req, http.StatusBadRequest, fmt.Errorf("something went wrong when setting the default language: %w", err))
}
// we successfully set the default language! time to redirect to the updated settings overview
h.redirect(router.AdminSettings, w, req)
}
func (h settingsHandler) setPrivacy(w http.ResponseWriter, req *http.Request) {
if !h.verifyPostRequirements(w, req) {
return
}
// handles error cases & make sures the member is an admin
currentMember := h.getMember(w, req)
if currentMember == nil {
return
}
@ -78,6 +90,44 @@ func (h settingsHandler) setPrivacy(w http.ResponseWriter, req *http.Request) {
}
// we successfully set the privacy mode! time to redirect to the updated settings overview
overview := h.urlTo(router.AdminSettings).String()
http.Redirect(w, req, overview, http.StatusFound)
h.redirect(router.AdminSettings, w, req)
}
/* common-use functions */
func (h settingsHandler) getMember(w http.ResponseWriter, req *http.Request) *roomdb.Member {
// get the member behind the POST
currentMember := members.FromContext(req.Context())
if currentMember == nil {
err := weberrors.ErrForbidden{Details: fmt.Errorf("not a registered member")}
h.r.Error(w, req, http.StatusInternalServerError, err)
return nil
}
// make sure the member is an admin
if currentMember.Role != roomdb.RoleAdmin {
err := weberrors.ErrForbidden{Details: fmt.Errorf("yr not an admin! naughty naughty")}
h.r.Error(w, req, http.StatusInternalServerError, err)
return nil
}
return currentMember
}
func (h settingsHandler) verifyPostRequirements(w http.ResponseWriter, req *http.Request) bool {
if req.Method != "POST" {
err := weberrors.ErrBadRequest{Where: "HTTP Method", Details: fmt.Errorf("expected POST not %s", req.Method)}
h.r.Error(w, req, http.StatusBadRequest, err)
return false
}
if err := req.ParseForm(); err != nil {
err = weberrors.ErrBadRequest{Where: "Form data", Details: err}
h.r.Error(w, req, http.StatusBadRequest, err)
return false
}
return true
}
func (h settingsHandler) redirect(route string, w http.ResponseWriter, req *http.Request) {
overview := h.urlTo(route).String()
http.Redirect(w, req, overview, http.StatusSeeOther)
}

View File

@ -64,6 +64,7 @@ func newSession(t *testing.T) *testSession {
ts.ConfigDB = new(mockdb.FakeRoomConfig)
// default mode for all tests
ts.ConfigDB.GetPrivacyModeReturns(roomdb.ModeCommunity, nil)
ts.ConfigDB.GetDefaultLanguageReturns("en", nil)
ts.DeniedKeysDB = new(mockdb.FakeDeniedKeysService)
ts.MembersDB = new(mockdb.FakeMembersService)
ts.PinnedDB = new(mockdb.FakePinnedNoticesService)
@ -107,7 +108,7 @@ func newSession(t *testing.T) *testSession {
i18ntesting.WriteReplacement(t)
testRepo := repo.New(testPath)
locHelper, err := i18n.New(testRepo)
locHelper, err := i18n.New(testRepo, ts.ConfigDB)
if err != nil {
t.Fatal(err)
}
@ -130,6 +131,8 @@ func newSession(t *testing.T) *testSession {
testFuncs["current_page_is"] = func(routeName string) bool { return true }
testFuncs["is_logged_in"] = func() *roomdb.Member { return &ts.User }
testFuncs["urlToNotice"] = func(name string) string { return "" }
testFuncs["language_count"] = func() int { return 1 }
testFuncs["list_languages"] = func(*url.URL, string) string { return "" }
testFuncs["relative_time"] = func(when time.Time) string { return humanize.Time(when) }
renderOpts := []render.Option{
@ -151,6 +154,7 @@ func newSession(t *testing.T) *testSession {
r,
ts.RoomState,
flashHelper,
locHelper,
Databases{
Aliases: ts.AliasesDB,
Config: ts.ConfigDB,

View File

@ -3,6 +3,7 @@
package handlers
import (
"bytes"
"fmt"
"html/template"
"net/http"
@ -73,7 +74,7 @@ func New(
m := router.CompleteApp()
urlTo := web.NewURLTo(m, netInfo)
locHelper, err := i18n.New(repo)
locHelper, err := i18n.New(repo, dbs.Config)
if err != nil {
return nil, err
}
@ -122,6 +123,36 @@ func New(
}
}),
render.InjectTemplateFunc("language_count", func(r *http.Request) interface{} {
return func() int {
return len(locHelper.ListLanguages())
}
}),
render.InjectTemplateFunc("list_languages", func(r *http.Request) interface{} {
return func(postRoute *url.URL, classList string) (template.HTML, error) {
languages := locHelper.ListLanguages()
var buf bytes.Buffer
for _, entry := range languages {
data := changeLanguageTemplateData{
PostRoute: postRoute.String(),
CSRFElement: csrf.TemplateField(r),
LangTag: entry.Tag,
RedirectPage: r.RequestURI,
Translation: entry.Translation,
ClassList: classList,
}
err = changeLanguageTemplate.Execute(&buf, data)
if err != nil {
return "", fmt.Errorf("Error while executing change language template: %w", err)
}
}
return (template.HTML)(buf.String()), nil
}
}),
render.InjectTemplateFunc("urlToNotice", func(r *http.Request) interface{} {
return func(name string) *url.URL {
noticeName := roomdb.PinnedNoticeName(name)
@ -167,6 +198,7 @@ func New(
}
CSRF := csrf.Protect(csrfKey,
csrf.Path("/"),
csrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
err := csrf.FailureReason(req)
// TODO: localize error?
@ -225,6 +257,7 @@ func New(
r,
roomState,
flashHelper,
locHelper,
admin.Databases{
Aliases: dbs.Aliases,
Config: dbs.Config,
@ -237,6 +270,28 @@ func New(
)
mainMux.Handle("/admin/", members.AuthenticateFromContext(r)(adminHandler))
// handle setting language
m.Get(router.CompleteSetLanguage).HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
lang := req.FormValue("lang")
previousRoute := req.FormValue("page")
session, err := cookieStore.Get(req, i18n.LanguageCookieName)
if err != nil {
eh.Handle(w, req, http.StatusInternalServerError, err)
return
}
session.Values["lang"] = lang
err = session.Save(req, w)
if err != nil {
err = fmt.Errorf("we failed to save the language session cookie %w\n", err)
eh.Handle(w, req, http.StatusInternalServerError, err)
return
}
http.Redirect(w, req, previousRoute, http.StatusSeeOther)
})
// landing page
m.Get(router.CompleteIndex).Handler(r.HTML("landing/index.tmpl", func(w http.ResponseWriter, req *http.Request) (interface{}, error) {
// TODO: try websocket upgrade (issue #)

View File

@ -116,7 +116,7 @@ func TestInviteConsumeInviteHTTP(t *testing.T) {
doc, resp := ts.Client.GetHTML(validAcceptURL)
a.Equal(http.StatusOK, resp.Code)
form := doc.Find("form#consume")
form := doc.Find("form#inviteConsume")
r.Equal(1, form.Length())
consumeInviteURLString, has := form.Attr("action")
@ -131,7 +131,7 @@ func TestInviteConsumeInviteHTTP(t *testing.T) {
})
// get the corresponding token from the page
csrfTokenElem := doc.Find("input[name='gorilla.csrf.Token']")
csrfTokenElem := form.Find(`input[name="gorilla.csrf.Token"]`)
a.Equal(1, csrfTokenElem.Length())
csrfName, has := csrfTokenElem.Attr("name")
a.True(has, "should have a name attribute")

View File

@ -0,0 +1,27 @@
package handlers
import "html/template"
type changeLanguageTemplateData struct {
PostRoute string
CSRFElement template.HTML
LangTag string
RedirectPage string
Translation string
ClassList string
}
var changeLanguageTemplate = template.Must(template.New("changeLanguageForm").Parse(`
<form
action="{{ .PostRoute }}"
method="POST"
>
{{ .CSRFElement }}
<input type="hidden" name="lang" value="{{ .LangTag }}">
<input type="hidden" name="page" value="{{ .RedirectPage }}">
<input
type="submit"
value="{{ .Translation }}"
class="{{ .ClassList }}"
/>
</form>`))

View File

@ -92,7 +92,7 @@ func TestNoticesEditButtonVisible(t *testing.T) {
csrfCookie := resp.Result().Cookies()
a.True(len(csrfCookie) > 0, "should have one cookie for CSRF protection validation")
csrfTokenElem := doc.Find("input[type=hidden]")
csrfTokenElem := doc.Find(`form#password-fallback input[type="hidden"]`)
a.Equal(1, csrfTokenElem.Length())
csrfName, has := csrfTokenElem.Attr("name")

View File

@ -0,0 +1,79 @@
package handlers
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/ssb-ngi-pointer/go-ssb-room/web/i18n"
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
)
func TestLanguageDefaultNoCookie(t *testing.T) {
ts := setup(t)
a := assert.New(t)
route := ts.URLTo(router.CompleteIndex)
html, res := ts.Client.GetHTML(route)
a.Equal(http.StatusOK, res.Code, "wrong HTTP status code")
languageForms := html.Find("#visitor-set-language form")
// two languages: english, deutsch => two <form> elements
a.Equal(2, languageForms.Length())
// verify there is no language cookie to set yet
cookieHeader := res.Header()["Set-Cookie"]
for _, cookie := range cookieHeader {
cookieName := strings.Split(cookie, "=")[0]
a.NotEqual(cookieName, i18n.LanguageCookieName)
}
}
func TestLanguageChooseGerman(t *testing.T) {
ts := setup(t)
a := assert.New(t)
route := ts.URLTo(router.CompleteIndex)
postEndpoint := ts.URLTo(router.CompleteSetLanguage)
html, res := ts.Client.GetHTML(route)
a.Equal(http.StatusOK, res.Code, "wrong HTTP status code")
csrfTokenElem := html.Find(`#visitor-set-language input[name="gorilla.csrf.Token"]`)
a.Equal(2, csrfTokenElem.Length())
csrfName, has := csrfTokenElem.First().Attr("name")
a.True(has, "should have a name attribute")
csrfValue, has := csrfTokenElem.First().Attr("value")
a.True(has, "should have value attribute")
// construct the post request fields, simulating picking a language
setLanguageFields := url.Values{
"lang": []string{"de"},
"page": []string{"/"},
csrfName: []string{csrfValue},
}
// set the referer header (important! otherwise our nicely crafted request yields a 500 :'()
var refererHeader = make(http.Header)
refererHeader.Set("Referer", "https://localhost")
ts.Client.SetHeaders(refererHeader)
// send the post request
postRes := ts.Client.PostForm(postEndpoint, setLanguageFields)
a.Equal(http.StatusSeeOther, postRes.Code, "wrong HTTP status code for sign in")
// verify there is one language cookie to set
cookieHeader := postRes.Header()["Set-Cookie"]
var languageCookies int
for _, cookie := range cookieHeader {
cookieName := strings.Split(cookie, "=")[0]
if cookieName == i18n.LanguageCookieName {
languageCookies += 1
}
}
a.Equal(1, languageCookies, "should have one language cookie set after posting")
}

View File

@ -0,0 +1,206 @@
LanguageName = "Deutsch"
# generic terms
GenericConfirm = "Ja"
GenericGoBack = "Zurück"
GenericSave = "Speichern"
GenericCreate = "Erstellen"
GenericSubmit = "Einreichen"
GenericPreview = "Vorschau"
GenericLanguage = "Sprache"
GenericOpenLink = "Verbindung öffnen"
PageNotFound = "Die angeforderte Seite wurde nicht gefunden."
PubKeyRefPlaceholder = "@ .ed25519"
# roles
RoleMember = "Mitglied"
RoleModerator = "Moderator"
RoleAdmin = "Administrator"
# navigation labels (should be single words or as short as possible)
NavAdminLanding = "Zuhause"
NavAdminDashboard = "Armaturenbrett"
NavAdminInvites = "Einladungen"
NavAdminNotices = "Hinweise"
# Error messages
ErrorAuthBadLogin = "Die angegebenen Authentifizierungsdaten (Benutzername oder Passwort) sind falsch."
ErrorNotFound = "Die Datenbank konnte den betreffenden Artikel nicht finden."
ErrorAlreadyAdded = "Der öffentliche Schlüssel <strong> {{.Key}} </ strong> ist bereits in der Liste enthalten."
ErrorPageNotFound = "Die angeforderte Seite <strong> ({{.Path}}) </ strong> ist nicht vorhanden."
ErrorNotAuthorized = "Sie sind nicht autorisiert auf diese Seite zuzugreifen."
ErrorForbidden = "Die Anforderung konnte wegen fehlender Berechtigungen ({{.Details}}) nicht ausgeführt werden."
ErrorBadRequest = "Bei Ihrer Anfrage ist ein Problem aufgetreten: {{.Where}} ({{.Details}}"
# authentication
################
AuthTitle = "Mitgliederauthentifizierung"
AuthWelcome = "Wenn Sie Mitglied dieses Raums sind, können Sie auf das interne Dashboard zugreifen. Klicken Sie unten auf Ihre bevorzugte Anmeldemethode:"
AuthSignIn = "Einloggen"
AuthSignOut = "Ausloggen"
# auth with ssb
AuthWithSSBTitle = "Mit SSB anmelden"
AuthWithSSBInstruct = "Einfache und sichere Methode, wenn Ihre SSB-App dies unterstützt."
AuthWithSSBWelcome = "Um sich mit Ihrer auf diesem Gerät gespeicherten SSB-Identität anzumelden, drücken Sie die Taste unten, um eine kompatible SSB-App zu öffnen, falls diese installiert ist."
AuthWithSSBInstructQR = "Wenn sich Ihre SSB-App auf einem anderen Gerät befindet, können Sie den folgenden QR-Code scannen, um sich mit der SSB-Identität dieses Geräts anzumelden."
AuthWithSSBError = "Anmeldung fehlgeschlagen. Stellen Sie sicher, dass Sie eine SSB-App verwenden, die diese Anmeldemethode unterstützt, und klicken Sie innerhalb einer Minute nach dem Öffnen dieser Seite auf die Schaltfläche oben."
# auth with password
AuthFallbackTitle = "Passwort anmelden"
AuthFallbackWelcome = "Eine Anmeldung mit Benutzername und Passwort ist nur möglich, wenn der Administrator Ihnen eines gegeben hat, da wir die Benutzerregistrierung nicht unterstützen."
AuthFallbackInstruct = "Diese Methode ist ein akzeptabler Fallback, wenn Sie einen Benutzernamen und ein Passwort haben."
# general dashboard stuff
#########################
AdminDashboardTitle = "Armaturenbrett"
AdminDashboardRoomID = "Der Ausweis dieses Zimmers lautet"
# privacy modes
###############
ModeOpen = "Öffnen"
ModeCommunity = "Gemeinschaft"
ModeRestricted = "Eingeschränkt"
SetPrivacyModeTitle = "Datenschutzmodus einstellen"
PrivacyModesTitle = "Datenschutzmodi"
RoomsSpecification = "Zimmer 2 Spezifikation"
ExplanationPrivacyModes = "Der Datenschutzmodus dieses Raums bestimmt, wer Einladungen erstellen und wer eine Verbindung zum Raum herstellen kann. Weitere Informationen finden Sie unter"
ExplanationOpen = "Öffnen Sie Einladungscodes, jeder kann eine Verbindung herstellen"
ExplanationCommunity = "Mitglieder können Einladungen erstellen, jeder kann eine Verbindung herstellen"
ExplanationRestricted = "Nur Administratoren / Mods können Einladungen erstellen, nur Mitglieder dürfen eine Verbindung herstellen."
Settings = "Einstellungen"
DefaultLanguageTitle = "Voreinstellung"
ExplanationDefaultLanguage = "Die Standard-Sprachoption steuert die Sprache der Raum-Weboberfläche, die für Erstbesucher angezeigt wird. Die verfügbaren Sprachoptionen werden durch die installierten Übersetzungsdateien definiert."
SetDefaultLanguageTitle = "Voreinstellung ändern"
# banned dashboard
##################
AdminDeniedKeysTitle = "Verboten"
AdminDeniedKeysWelcome = "Auf dieser Seite können SSB-IDs gesperrt werden, damit sie nicht mehr auf den Raum zugreifen können."
AdminDeniedKeysAdd = "Hinzufügen"
AdminDeniedKeysAdded = "Schlüssel wurde zur Liste hinzugefügt."
AdminDeniedKeysRemove = "Entfernen"
AdminDeniedKeysComment = "Kommentar"
AdminDeniedKeysCommentDescription = "Die Person, die dieses Verbot hinzugefügt hat, hat den folgenden Kommentar hinzugefügt"
AdminDeniedKeysRemoveConfirmWelcome = "Sind Sie sicher, dass Sie dieses Verbot aufheben möchten? Sie werden wieder Zugang zum Raum haben."
AdminDeniedKeysRemoveConfirmTitle = "Mitgliederentfernung bestätigen"
# members dashboard
###################
AdminMembersTitle = "Mitglieder"
AdminMembersWelcome = "Hier sehen Sie alle Mitglieder des Raums und Möglichkeiten, neue hinzuzufügen (anhand ihrer SSB-ID) oder vorhandene zu entfernen."
AdminMembersAdd = "Hinzufügen"
AdminMembersRemoveConfirmTitle = "Mitgliederentfernung bestätigen"
AdminMembersRemoveConfirmWelcome = "Sind Sie sicher, dass Sie dieses Mitglied entfernen möchten? Sie verlieren ihren Alias, wenn sie einen haben."
AdminMemberDetailsTitle = "Mitgliederdetails"
AdminMemberDetailsSSBID = "SSB-Kennung"
AdminMemberDetailsRole = "Berechtigungsstufe"
AdminMemberDetailsAliases = "Aliase"
AdminMemberDetailsAliasRevoke = "Widerrufen"
AdminMemberDetailsAliasRevoked = "Alias wurde widerrufen"
AdminMemberDetailsExclusion = "Ausschluss aus diesem Raum"
AdminMemberDetailsRemove = "Mitglied entfernen"
AdminMemberAdded = "Mitglied erfolgreich hinzugefügt."
AdminMemberUpdated = "Mitglied aktualisiert."
AdminMemberRemoved = "Mitglied entfernt."
AdminAliasesRevoke = "Widerrufen"
AdminAliasesRevokeConfirmTitle = "Alias widerrufen"
AdminAliasesRevokeConfirmWelcome = "Sind Sie sicher, dass Sie diesen Alias widerrufen möchten?"
# invite dashboard
##################
AdminInvitesTitle = "Einladungen"
AdminInvitesWelcome = "Erstellen Sie Einladungs-Token für Personen, die noch keine Mitglieder dieses Raums sind. Auf dieser Seite sehen Sie auch zuvor erstellte Einladungen, die von neuen Mitgliedern noch nicht beansprucht werden."
AdminInvitesCreate = "Neue Einladung erstellen"
AdminInvitesCreatedAtColumn = "Hergestellt in"
AdminInvitesCreatorColumn = "Erstellt von"
AdminInvitesActionColumn = "Aktion"
AdminInviteRevoke = "Widerrufen"
AdminInviteRevokeConfirmTitle = "Widerruf der Einladung bestätigen"
AdminInviteRevokeConfirmWelcome = "Sind Sie sicher, dass Sie diese Einladung entfernen möchten? Wenn Sie sie bereits gesendet haben, können sie sie nicht verwenden."
# TODO: add placeholder support to the template helpers (https://github.com/ssb-ngi-pointer/go-ssb-room/issues/60)
AdminInviteCreatedBy = "Erstellt von:"
AdminInviteSuggestedAliasIs = "Der vorgeschlagene Alias lautet:"
AdminInviteSuggestedAliasIsShort = "Alias:"
AdminInviteCreatedTitle = "Einladung erfolgreich erstellt!"
AdminInviteCreatedInstruct = "Kopieren Sie nun den folgenden Link und fügen Sie ihn in einen Freund ein, den Sie in diesen Raum einladen möchten."
# public invites
################
InviteFacade = "Raum betreten"
InviteFacadeTitle = "Raum betreten"
InviteFacadeWelcome = "Sie haben die Erlaubnis, Mitglied dieses Raums zu werden, weil jemand diese Einladung mit Ihnen geteilt hat."
InviteFacadeInstruct = "Um die Einladung zu erhalten, klicken Sie auf die Schaltfläche unten, um eine kompatible SSB-App zu öffnen, falls diese installiert ist."
InviteFacadeJoin = "Tritt diesem Raum bei"
InviteFacadeWaiting = "SSB App öffnen"
InviteFacadeInstructQR = "Wenn sich Ihre SSB-App auf einem anderen Gerät befindet, können Sie den folgenden QR-Code scannen, um Ihre Einladung auf diesem Gerät zu erhalten:"
InviteFacadeFallbackWelcome = "Sind Sie neu bei SSB? Es scheint, dass Sie keine SSB-App haben, die diesen Link verstehen kann. Sie können eine dieser Apps installieren:"
InviteFacadeFallbackManyverse = "Installiere Manyverse"
InviteFacadeFallbackInsertID = "SSB-ID einfügen"
InviteInsertWelcome = "Sie können Ihre Einladung anfordern, indem Sie unten Ihre SSB-ID eingeben. Danach können Sie in Ihrer SSB-App eine Verbindung zum Raum herstellen."
InviteConsumedTitle = "Einladung angenommen!"
InviteConsumedWelcome = "Sie sind jetzt Mitglied dieses Raums. Wenn Sie eine Multiserver-Adresse benötigen, um eine Verbindung zum Raum herzustellen, können Sie die folgende kopieren und einfügen:"
# notices (mini-CMS)
####################
NoticeEditTitle = "Hinweis bearbeiten"
NoticeList = "Hinweise"
NoticeListWelcome = "Hier können Sie den Inhalt der Zielseite und anderer wichtiger Dokumente wie Verhaltenskodex und Datenschutzbestimmungen verwalten."
NoticeAddTranslation = "Hinzufügen"
NoticeUpdated = "Hinweis aktualisiert"
NoticeCodeOfConduct = "Verhaltenskodex"
NoticeNews = "Nachrichten"
NoticeDescription = "Beschreibung"
NoticePrivacyPolicy = "Datenschutz-Bestimmungen"
# Plurals
#########
# These need to use this form and get {{.Count}}
# [Label]
# one = singular
# other = {{.Count}} things
[MemberCount]
description = "Anzahl der Mitglieder"
one = "1 Mitglied"
other = "{{.Count}} Mitglieder"
[ListCount]
description = "generische Liste"
one = "Es gibt einen Punkt auf der Liste"
other = "Die Liste enthält {{.Count}} Elemente"
[AdminRoomCount]
description = "Die Anzahl der Personen in einem Raum"
one = "Es ist eine Person im Raum"
other = "Es sind {{.Count}} Personen im Raum"
[AdminInvitesCount]
description = "die Anzahl der Einladungen, die noch nicht beansprucht wurden"
one = "1 Einladung noch nicht beansprucht"
other = "{{.Count}} lädt noch nicht beanspruchte ein"

View File

@ -1,5 +1,8 @@
# default localiztion file for english
# the name of this language, in its language. Used for language picking
LanguageName = "English"
# generic terms
GenericConfirm = "Yes"
GenericGoBack = "Back"
@ -80,6 +83,10 @@ ExplanationOpen = "Open invite codes, anyone may connect"
ExplanationCommunity = "Members can create invites, anyone may connect"
ExplanationRestricted = "Only admins/mods can create invites, only members may connect"
DefaultLanguageTitle = "Default Language"
ExplanationDefaultLanguage = "The default language option controls the room web interface language displayed for first time visitors. The available languages options are defined by the installed translation files."
SetDefaultLanguageTitle = "Set Default Language"
Settings = "Settings"
# banned dashboard

View File

@ -11,25 +11,51 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"github.com/BurntSushi/toml"
"github.com/gorilla/sessions"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/web"
"go.mindeco.de/http/render"
"golang.org/x/text/language"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/repo"
)
type Helper struct {
bundle *i18n.Bundle
const LanguageCookieName = "gossbroom-language"
type TagTranslation struct {
Tag string
Translation string
}
func New(r repo.Interface) (*Helper, error) {
type Helper struct {
bundle *i18n.Bundle
languages []TagTranslation
cookieStore *sessions.CookieStore
config roomdb.RoomConfig
}
func New(r repo.Interface, config roomdb.RoomConfig) (*Helper, error) {
bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
cookieCodec, err := web.LoadOrCreateCookieSecrets(r)
if err != nil {
return nil, err
}
cookieStore := &sessions.CookieStore{
Codecs: cookieCodec,
Options: &sessions.Options{
Path: "/",
MaxAge: 2 * 60 * 60, // two hours in seconds // TODO: configure
},
}
// parse toml files and add them to the bundle
walkFn := func(path string, info os.FileInfo, rs io.Reader, err error) error {
if err != nil {
@ -57,7 +83,7 @@ func New(r repo.Interface) (*Helper, error) {
}
// walk the embedded defaults
err := fs.WalkDir(Defaults, ".", func(path string, d fs.DirEntry, err error) error {
err = fs.WalkDir(Defaults, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
@ -114,22 +140,57 @@ func New(r repo.Interface) (*Helper, error) {
return nil, fmt.Errorf("i18n: failed to iterate localizations: %w", err)
}
return &Helper{bundle: bundle}, nil
// create a mapping of language tags to the translated language names
langmap := listLanguages(bundle)
return &Helper{bundle: bundle, languages: langmap, cookieStore: cookieStore, config: config}, nil
}
func (h Helper) GetRenderFuncs() []render.Option {
var opts = []render.Option{
render.InjectTemplateFunc("i18npl", func(r *http.Request) interface{} {
loc := h.FromRequest(r)
return loc.LocalizePlurals
}),
func listLanguages(bundle *i18n.Bundle) []TagTranslation {
languageTags := bundle.LanguageTags()
tags := make([]string, 0, len(languageTags))
langslice := make([]TagTranslation, 0, len(languageTags))
render.InjectTemplateFunc("i18n", func(r *http.Request) interface{} {
loc := h.FromRequest(r)
return loc.LocalizeSimple
}),
// convert from i18n language tags to a slice of strings
for _, langTag := range languageTags {
tags = append(tags, langTag.String())
}
return opts
// sort the slice of language tag strings
sort.Strings(tags)
// now that we have a known order, construct a TagTranslation slice mapping language tags to their translations
for _, langTag := range tags {
var l Localizer
l.loc = i18n.NewLocalizer(bundle, langTag)
msg, err := l.loc.Localize(&i18n.LocalizeConfig{
MessageID: "LanguageName",
})
if err != nil {
msg = langTag
}
langslice = append(langslice, TagTranslation{Tag: langTag, Translation: msg})
}
return langslice
}
// ListLanguages returns a slice of the room's translated languages.
// The entries of the slice are of the type TagTranslation, consisting of the fields Tag and Translation.
// Each Tag fields is a language tag (as strings), and the field Translation is the corresponding translated language
// name of that language tag.
// Example: {Tag: en, Translation: English}, {Tag: sv, Translation: Svenska} {Tag: de, Translation: Deutsch}
func (h Helper) ListLanguages() []TagTranslation {
return h.languages
}
func (h Helper) ChooseTranslation(requestedTag string) string {
for _, entry := range h.languages {
if entry.Tag == requestedTag {
return entry.Translation
}
}
return requestedTag
}
type Localizer struct {
@ -146,11 +207,45 @@ func (h Helper) newLocalizer(lang string, accept ...string) *Localizer {
// FromRequest returns a new Localizer for the passed helper,
// using form value 'lang' and Accept-Language http header from the passed request.
// TODO: user settings/cookie values?
// If a language cookie is detected, then it takes precedence over the form value & Accept-Lanuage header.
func (h Helper) FromRequest(r *http.Request) *Localizer {
lang := r.FormValue("lang")
accept := r.Header.Get("Accept-Language")
return h.newLocalizer(lang, accept)
session, err := h.cookieStore.Get(r, LanguageCookieName)
if err != nil {
return h.newLocalizer(lang, accept)
}
prevCookie := session.Values["lang"]
if prevCookie != nil {
return h.newLocalizer(prevCookie.(string), lang, accept)
}
defaultLang, err := h.config.GetDefaultLanguage(r.Context())
// if we don't have a default language set, then fallback to whatever we have left :^)
if err != nil {
return h.newLocalizer(lang, accept)
}
// if we don't have a user cookie set, then use the room's default language setting
return h.newLocalizer(defaultLang, accept)
}
func (h Helper) GetRenderFuncs() []render.Option {
var opts = []render.Option{
render.InjectTemplateFunc("i18npl", func(r *http.Request) interface{} {
loc := h.FromRequest(r)
return loc.LocalizePlurals
}),
render.InjectTemplateFunc("i18n", func(r *http.Request) interface{} {
loc := h.FromRequest(r)
return loc.LocalizeSimple
}),
}
return opts
}
func (l Localizer) LocalizeSimple(messageID string) string {

View File

@ -0,0 +1,23 @@
package i18ntesting
import (
"github.com/stretchr/testify/assert"
"path/filepath"
"testing"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/repo"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb/mockdb"
"github.com/ssb-ngi-pointer/go-ssb-room/web/i18n"
)
func TestListLanguages(t *testing.T) {
configDB := new(mockdb.FakeRoomConfig)
configDB.GetDefaultLanguageReturns("en", nil)
r := repo.New(filepath.Join("testrun", t.Name()))
a := assert.New(t)
helper, err := i18n.New(r, configDB)
a.NoError(err)
t.Log(helper)
translation := helper.ChooseTranslation("en")
a.Equal(translation, "English")
}

View File

@ -9,8 +9,9 @@ const (
AdminDashboard = "admin:dashboard"
AdminMenu = "admin:menu"
AdminSettings = "admin:settings:overview"
AdminSettingsSetPrivacy = "admin:settings:set-privacy"
AdminSettings = "admin:settings:overview"
AdminSettingsSetPrivacy = "admin:settings:set-privacy"
AdminSettingsSetLanguage = "admin:settings:set-language"
AdminAliasesRevokeConfirm = "admin:aliases:revoke:confirm"
AdminAliasesRevoke = "admin:aliases:revoke"
@ -49,6 +50,7 @@ func Admin(m *mux.Router) *mux.Router {
m.Path("/settings").Methods("GET").Name(AdminSettings)
m.Path("/settings/set-privacy").Methods("POST").Name(AdminSettingsSetPrivacy)
m.Path("/settings/set-language").Methods("POST").Name(AdminSettingsSetLanguage)
m.Path("/menu").Methods("GET").Name(AdminMenu)

View File

@ -14,6 +14,8 @@ const (
CompleteNoticeShow = "complete:notice:show"
CompleteNoticeList = "complete:notice:list"
CompleteSetLanguage = "complete:set-language"
CompleteAliasResolve = "complete:alias:resolve"
CompleteInviteFacade = "complete:invite:accept"
@ -42,5 +44,7 @@ func CompleteApp() *mux.Router {
m.Path("/notice/show").Methods("GET").Name(CompleteNoticeShow)
m.Path("/notice/list").Methods("GET").Name(CompleteNoticeList)
m.Path("/set-language").Methods("POST").Name(CompleteSetLanguage)
return m
}

View File

@ -4,55 +4,71 @@
class="text-3xl tracking-tight font-black text-black mt-2 mb-0"
>{{ i18n "Settings" }}</h1>
<div class="flex flex-col-reverse sm:flex-row justify-start items-stretch ">
<div class="max-w-lg">
<h2 class="text-xl tracking-tight font-bold text-black mt-2 mb-2">{{ i18n "PrivacyModesTitle" }}</h2>
<p class="mb-4">
{{ i18n "ExplanationPrivacyModes" }}
<a class="text-pink-600 underline" href="https://ssb-ngi-pointer.github.io/rooms2/#privacy-modes">{{ i18n "RoomsSpecification" }}</a>.
</p>
<h3 class="text-gray-400 text-sm font-bold mb-2">{{ i18n "SetPrivacyModeTitle" }}</h3>
<details class="mb-8 self-start w-96" id="change-privacy">
<summary class="px-3 py-1 w-96 rounded shadow bg-white ring-1 ring-gray-300 hover:bg-gray-100 cursor-pointer">
{{ i18n .CurrentMode.String }}
</summary>
<div class="max-w-2xl">
<h2 class="text-xl tracking-tight font-bold text-black mt-2 mb-2">{{ i18n "PrivacyModesTitle" }}</h2>
<p class="mb-4">
{{ i18n "ExplanationPrivacyModes" }}
<a class="text-pink-600 underline" href="https://ssb-ngi-pointer.github.io/rooms2/#privacy-modes">{{ i18n "RoomsSpecification" }}</a>.
</p>
<h3 class="text-gray-400 text-sm font-bold mb-2">{{ i18n "SetPrivacyModeTitle" }}</h3>
<details class="mb-8 self-start max-w-sm" id="change-privacy">
<summary class="px-3 py-1 max-w-sm rounded shadow bg-white ring-1 ring-gray-300 hover:bg-gray-100 cursor-pointer">
{{ i18n .CurrentMode.String }}
</summary>
<div class="absolute w-96 z-10 bg-white mt-2 shadow-xl ring-1 ring-gray-200 rounded divide-y flex flex-col items-stretch overflow-hidden">
{{ range .PrivacyModes }}
{{ if ne . $.CurrentMode }}
<form
action="{{ urlTo "admin:settings:set-privacy" }}"
method="POST"
>
{{ $.csrfField }}
<input type="hidden" name="privacy_mode" value="{{.}}">
<input
type="submit"
value="{{ i18n .String }}"
class="pl-10 pr-3 py-2 w-full text-left bg-white text-gray-700 hover:text-gray-900 hover:bg-gray-50 cursor-pointer"
/>
</form>
{{ else }}
<div class="pr-3 py-2 text-gray-600 flex flex-row items-center cursor-default">
<div class="w-10 flex flex-row items-center justify-center">
<svg class="w-4 h-4" viewBox="0 0 24 24">
<path fill="currentColor" d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z" />
</svg>
</div>
<span>{{ i18n .String }}</span>
<div class="absolute max-w-sm z-10 bg-white mt-2 shadow-xl ring-1 ring-gray-200 rounded divide-y flex flex-col items-stretch overflow-hidden">
{{ range .PrivacyModes }}
{{ if ne . $.CurrentMode }}
<form
action="{{ urlTo "admin:settings:set-privacy" }}"
method="POST"
>
{{ $.csrfField }}
<input type="hidden" name="privacy_mode" value="{{.}}">
<input
type="submit"
value="{{ i18n .String }}"
class="pl-10 pr-3 py-2 w-full text-left bg-white text-gray-700 hover:text-gray-900 hover:bg-gray-50 cursor-pointer"
/>
</form>
{{ else }}
<div class="pr-3 py-2 text-gray-600 flex flex-row items-center cursor-default">
<div class="w-10 flex flex-row items-center justify-center">
<svg class="w-4 h-4" viewBox="0 0 24 24">
<path fill="currentColor" d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z" />
</svg>
</div>
{{end}}
{{end}}
</div>
</details>
<div class="grid max-w-lg grid-cols-3 gap-y-2 mb-8">
<div class="text-xl text-gray-500 font-bold">{{ i18n "ModeOpen" }}</div>
<div class="text-md col-span-2 italic">{{ i18n "ExplanationOpen" }}</div>
<div class="text-xl text-gray-500 font-bold">{{ i18n "ModeCommunity" }}</div>
<div class="text-md col-span-2 italic">{{ i18n "ExplanationCommunity" }}</div>
<div class="text-xl text-gray-500 font-bold">{{ i18n "ModeRestricted" }}</div>
<div class="text-md col-span-2 italic">{{ i18n "ExplanationRestricted" }}</div>
</div>
<span>{{ i18n .String }}</span>
</div>
{{end}}
{{end}}
</div>
</details>
<div class="grid max-w-lg grid-cols-3 gap-y-2 mb-8">
<div class="text-xl text-gray-500 font-bold">{{ i18n "ModeOpen" }}</div>
<div class="text-md col-span-2 italic">{{ i18n "ExplanationOpen" }}</div>
<div class="text-xl text-gray-500 font-bold">{{ i18n "ModeCommunity" }}</div>
<div class="text-md col-span-2 italic">{{ i18n "ExplanationCommunity" }}</div>
<div class="text-xl text-gray-500 font-bold">{{ i18n "ModeRestricted" }}</div>
<div class="text-md col-span-2 italic">{{ i18n "ExplanationRestricted" }}</div>
</div>
</div>
<div class="max-w-2xl">
<h2 class="text-xl tracking-tight font-bold text-black mt-2 mb-2">{{ i18n "DefaultLanguageTitle" }}</h2>
<p class="mb-4">
{{ i18n "ExplanationDefaultLanguage" }}
</p>
<h3 class="text-gray-400 text-sm font-bold mb-2">{{ i18n "SetDefaultLanguageTitle" }}</h3>
<details class="mb-8 self-start max-w-sm">
<summary id="language-summary" class="px-3 py-1 max-w-sm rounded shadow bg-white ring-1 ring-gray-300 hover:bg-gray-100 cursor-pointer">
{{ $.CurrentLanguage }}
</summary>
<div class="mt-2 bg-gray bg-white px-3 rounded ring-1 ring-gray-300">
{{ $adminSetLanguageUrl := urlTo "admin:settings:set-language" }}
{{ list_languages $adminSetLanguageUrl "pl-10 pr-3 py-2 w-full text-left bg-white text-gray-700 hover:text-gray-900 hover:bg-gray-50 cursor-pointer" }}
</div>
</details>
</div>
</div>
{{end}}

View File

@ -12,7 +12,7 @@
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png">
<link rel="manifest" href="/assets/favicon/site.webmanifest">
</head>
<body class="bg-gray-100">
<body class="bg-gray-100 overflow-y-scroll">
<div class="sm:mx-auto sm:container">
<div class="flex flex-row justify-end space-x-4 my-4">
{{$user := is_logged_in}}
@ -70,23 +70,40 @@
{{block "footer" .}}
{{$cocUrl := urlToNotice "NoticeCodeOfConduct"}}
{{$ppUrl := urlToNotice "NoticePrivacyPolicy"}}
<footer class="mb-4 flex flex-row items-center justify-center divide-x divide-gray-300">
<a
href="{{urlTo "complete:index"}}"
class="px-4 text-gray-500 hover:underline"
>{{i18n "NavAdminLanding"}}</a>
{{if $cocUrl}}
<a
href="{{$cocUrl}}"
class="px-4 text-gray-500 hover:underline"
>{{i18n "NoticeCodeOfConduct"}}</a>
{{end}}
{{if $ppUrl}}
<a
href="{{$ppUrl}}"
class="px-4 text-gray-500 hover:underline"
>{{i18n "NoticePrivacyPolicy"}}</a>
{{end}}
{{$setLanguageUrl := urlTo "complete:set-language"}}
<footer class="grid auto-rows-min mb-12">
<div class="mb-4 flex flex-row items-center justify-center divide-x divide-gray-300">
<a
href="{{urlTo "complete:index"}}"
class="px-4 text-gray-500 hover:underline"
>{{i18n "NavAdminLanding"}}</a>
{{if $cocUrl}}
<a
href="{{$cocUrl}}"
class="px-4 text-gray-500 hover:underline"
>{{i18n "NoticeCodeOfConduct"}}</a>
{{end}}
{{if $ppUrl}}
<a
href="{{$ppUrl}}"
class="px-4 text-gray-500 hover:underline"
>{{i18n "NoticePrivacyPolicy"}}</a>
{{end}}
</div>
<div class="flex justify-center">
{{ $languages := language_count }}
{{ if gt $languages 1 }}
<details class="w-72">
<summary
class="mb-2 mx-auto px-3 py-1 text-gray-500 w-32 rounded shadow bg-gray-50 ring-1 ring-gray-300 hover:bg-gray-100 cursor-pointer">
Language
</summary>
<div id="visitor-set-language" class="grid grid-cols-2 justify-items-center gap-x-1">
{{ list_languages $setLanguageUrl "text-gray-500 hover:underline cursor-pointer" }}
</div>
</details>
{{ end }}
</div>
</footer>
{{end}}
</div>

View File

@ -4,7 +4,7 @@
<span id="welcome" class="text-center mt-8">{{i18n "InviteInsertWelcome"}}</span>
<form
id="consume"
id="inviteConsume"
action="{{urlTo "complete:invite:consume"}}"
method="POST"
class="flex flex-col items-center self-stretch"
@ -24,4 +24,4 @@
>{{i18n "GenericSubmit"}}</button>
</form>
</div>
{{ end }}
{{ end }}