fix: auto reload on multiple browser windows/tabs closes #6; fix: chatroom example not compiling
This commit is contained in:
@@ -83,7 +83,7 @@ func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H {
|
|||||||
return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts))
|
return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnEnterKey returns a via.h DOM attribute that triggers when a key is pressed.
|
// OneyDown returns a via.h DOM attribute that triggers when a key is pressed.
|
||||||
// key: optional, see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
|
// key: optional, see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
|
||||||
// Example: OnKeyDown("Enter")
|
// Example: OnKeyDown("Enter")
|
||||||
func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.H {
|
func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.H {
|
||||||
|
|||||||
12
context.go
12
context.go
@@ -13,9 +13,10 @@ import (
|
|||||||
|
|
||||||
// Context is the living bridge between Go and the browser.
|
// Context is the living bridge between Go and the browser.
|
||||||
//
|
//
|
||||||
// It binds user state and actions, manages reactive signals, and defines UI through View.
|
// It holds runtime state, defines actions, manages reactive signals, and defines UI through View.
|
||||||
type Context struct {
|
type Context struct {
|
||||||
id string
|
id string
|
||||||
|
route string
|
||||||
app *V
|
app *V
|
||||||
view func() h.H
|
view func() h.H
|
||||||
componentRegistry map[string]*Context
|
componentRegistry map[string]*Context
|
||||||
@@ -60,7 +61,7 @@ func (c *Context) View(f func() h.H) {
|
|||||||
// })
|
// })
|
||||||
func (c *Context) Component(f func(c *Context)) func() h.H {
|
func (c *Context) Component(f func(c *Context)) func() h.H {
|
||||||
id := c.id + "/_component/" + genRandID()
|
id := c.id + "/_component/" + genRandID()
|
||||||
compCtx := newContext(id, c.app)
|
compCtx := newContext(id, c.route, c.app)
|
||||||
if c.isComponent() {
|
if c.isComponent() {
|
||||||
compCtx.parentPageCtx = c.parentPageCtx
|
compCtx.parentPageCtx = c.parentPageCtx
|
||||||
} else {
|
} else {
|
||||||
@@ -297,14 +298,15 @@ func (c *Context) ExecScript(s string) {
|
|||||||
_ = sse.ExecuteScript(s)
|
_ = sse.ExecuteScript(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newContext(id string, a *V) *Context {
|
func newContext(id string, route string, app *V) *Context {
|
||||||
if a == nil {
|
if app == nil {
|
||||||
log.Fatalf("create context failed: app pointer is nil")
|
log.Fatalf("create context failed: app pointer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Context{
|
return &Context{
|
||||||
id: id,
|
id: id,
|
||||||
app: a,
|
route: route,
|
||||||
|
app: app,
|
||||||
componentRegistry: make(map[string]*Context),
|
componentRegistry: make(map[string]*Context),
|
||||||
actionRegistry: make(map[string]func()),
|
actionRegistry: make(map[string]func()),
|
||||||
signals: make(map[string]*signal),
|
signals: make(map[string]*signal),
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -5,6 +5,7 @@ go 1.25.3
|
|||||||
require maragu.dev/gomponents v1.2.0
|
require maragu.dev/gomponents v1.2.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/starfederation/datastar-go v1.0.3
|
github.com/starfederation/datastar-go v1.0.3
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
)
|
)
|
||||||
@@ -16,5 +17,6 @@ require (
|
|||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
golang.org/x/sys v0.13.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
5
go.sum
5
go.sum
@@ -6,6 +6,8 @@ github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUS
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
|
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
|
||||||
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
|
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
|
||||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||||
@@ -33,6 +35,9 @@ github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g
|
|||||||
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
|
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||||
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ func main() {
|
|||||||
DevMode: true,
|
DevMode: true,
|
||||||
DocumentTitle: "ViaChat",
|
DocumentTitle: "ViaChat",
|
||||||
LogLvl: via.LogLevelInfo,
|
LogLvl: via.LogLevelInfo,
|
||||||
Plugins: []via.Plugin{via.SigQuitPlugin},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
v.AppendToHead(
|
v.AppendToHead(
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func main() {
|
|||||||
|
|
||||||
c.View(func() h.H {
|
c.View(func() h.H {
|
||||||
return h.Div(
|
return h.Div(
|
||||||
h.H1(h.Text("Live Reload with Via DevMode !!!")),
|
h.H1(h.Text("⚡Via Live Reload!")),
|
||||||
h.P(h.Textf("Count: %d", data.Count)),
|
h.P(h.Textf("Count: %d", data.Count)),
|
||||||
h.P(h.Span(h.Text("Step: ")), h.Span(step.Text())),
|
h.P(h.Span(h.Text("Step: ")), h.Span(step.Text())),
|
||||||
h.Label(
|
h.Label(
|
||||||
|
|||||||
98
via.go
98
via.go
@@ -36,7 +36,6 @@ type V struct {
|
|||||||
documentHeadIncludes []h.H
|
documentHeadIncludes []h.H
|
||||||
documentFootIncludes []h.H
|
documentFootIncludes []h.H
|
||||||
devModePageInitFnMap map[string]func(*Context)
|
devModePageInitFnMap map[string]func(*Context)
|
||||||
devModePageInitFnMapMutex sync.Mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *V) logErr(c *Context, format string, a ...any) {
|
func (v *V) logErr(c *Context, format string, a ...any) {
|
||||||
@@ -77,7 +76,7 @@ func (v *V) logDebug(c *Context, format string, a ...any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config overrides the default configuration with the given configuration options.
|
// Config overrides the default configuration with the given options.
|
||||||
func (v *V) Config(cfg Options) {
|
func (v *V) Config(cfg Options) {
|
||||||
if cfg.LogLvl != v.cfg.LogLvl {
|
if cfg.LogLvl != v.cfg.LogLvl {
|
||||||
v.cfg.LogLvl = cfg.LogLvl
|
v.cfg.LogLvl = cfg.LogLvl
|
||||||
@@ -132,19 +131,20 @@ func (v *V) AppendToFoot(elements ...h.H) {
|
|||||||
// })
|
// })
|
||||||
func (v *V) Page(route string, initContextFn func(c *Context)) {
|
func (v *V) Page(route string, initContextFn func(c *Context)) {
|
||||||
if v.cfg.DevMode {
|
if v.cfg.DevMode {
|
||||||
v.devModePageInitFnMapMutex.Lock()
|
|
||||||
defer v.devModePageInitFnMapMutex.Unlock()
|
|
||||||
v.devModePageInitFnMap[route] = initContextFn
|
v.devModePageInitFnMap[route] = initContextFn
|
||||||
}
|
}
|
||||||
v.mux.HandleFunc("GET "+route, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
v.mux.HandleFunc("GET "+route, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v.logDebug(nil, "GET %s", route)
|
||||||
if strings.Contains(r.URL.Path, "favicon") {
|
if strings.Contains(r.URL.Path, "favicon") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := fmt.Sprintf("%s_/%s", route, genRandID())
|
id := fmt.Sprintf("%s_/%s", route, genRandID())
|
||||||
c := newContext(id, v)
|
c := newContext(id, route, v)
|
||||||
v.logDebug(c, "GET %s", route)
|
|
||||||
initContextFn(c)
|
initContextFn(c)
|
||||||
v.registerCtx(c.id, c)
|
v.registerCtx(c.id, c)
|
||||||
|
if v.cfg.DevMode {
|
||||||
|
v.Persist()
|
||||||
|
}
|
||||||
headElements := v.documentHeadIncludes
|
headElements := v.documentHeadIncludes
|
||||||
headElements = append(headElements, h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))))
|
headElements = append(headElements, h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))))
|
||||||
headElements = append(headElements, h.Meta(h.Data("init", "@get('/_sse')")))
|
headElements = append(headElements, h.Meta(h.Data("init", "@get('/_sse')")))
|
||||||
@@ -195,68 +195,56 @@ func (v *V) HandleFunc(pattern string, f http.HandlerFunc) {
|
|||||||
|
|
||||||
// Start starts the Via HTTP server on the given address.
|
// Start starts the Via HTTP server on the given address.
|
||||||
func (v *V) Start() {
|
func (v *V) Start() {
|
||||||
v.logInfo(nil, "via started on address: %s", v.cfg.ServerAddress)
|
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
|
||||||
log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.mux))
|
log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.mux))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *V) persistCtx(c *Context) error {
|
func (v *V) Persist() {
|
||||||
idsplit := strings.Split(c.id, "_")
|
p := filepath.Join(".via", "devmode", "ctx.json")
|
||||||
if len(idsplit) < 2 {
|
|
||||||
return fmt.Errorf("failed to identify ctx page route")
|
|
||||||
}
|
|
||||||
route := idsplit[0]
|
|
||||||
ctxmap := map[string]any{"id": c.id, "route": route}
|
|
||||||
|
|
||||||
p := path.Join(".via", "devmode", "ctx.json")
|
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create directory for devmode files: %v", err)
|
log.Fatalf("failed to create directory for devmode files: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Create(p)
|
file, err := os.Create(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create file in devmode directory: %v", err)
|
log.Printf("devmode: failed to persist ctx: %v", err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
m := make(map[string]string)
|
||||||
|
for ctxID, ctx := range v.contextRegistry {
|
||||||
|
m[ctxID] = ctx.route
|
||||||
|
}
|
||||||
encoder := json.NewEncoder(file)
|
encoder := json.NewEncoder(file)
|
||||||
if err := encoder.Encode(ctxmap); err != nil {
|
if err := encoder.Encode(m); err != nil {
|
||||||
return fmt.Errorf("failed to encode ctx: %s", err)
|
log.Printf("devmode: failed to persist ctx: %s", err)
|
||||||
}
|
}
|
||||||
return nil
|
log.Printf("devmode persisted ctx registryv")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *V) restoreCtx() *Context {
|
func (v *V) Restore() {
|
||||||
p := path.Join(".via", "devmode", "ctx.json")
|
p := path.Join(".via", "devmode", "ctx.json")
|
||||||
file, err := os.Open(p)
|
file, err := os.Open(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Error opening file:", err)
|
v.logErr(nil, "devmode failed to restore ctx: %v", err)
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
var ctxmap map[string]any
|
var ctxRegMap map[string]string
|
||||||
if err := json.NewDecoder(file).Decode(&ctxmap); err != nil {
|
if err := json.NewDecoder(file).Decode(&ctxRegMap); err != nil {
|
||||||
fmt.Println("Error restoring ctx:", err)
|
v.logErr(nil, "devmode failed to restore ctx: %v", err)
|
||||||
return nil
|
return
|
||||||
}
|
|
||||||
ctxId, ok := ctxmap["id"].(string)
|
|
||||||
if !ok {
|
|
||||||
fmt.Println("Error restoring ctx")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pageRoute, ok := ctxmap["route"].(string)
|
|
||||||
if !ok {
|
|
||||||
fmt.Println("Error restoring ctx")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
for ctxID, pageRoute := range ctxRegMap {
|
||||||
pageInitFn, ok := v.devModePageInitFnMap[pageRoute]
|
pageInitFn, ok := v.devModePageInitFnMap[pageRoute]
|
||||||
if !ok {
|
if !ok {
|
||||||
fmt.Println("devmode failed to restore ctx: ")
|
fmt.Println("devmode failed to restore ctx: page init func of ctx not found")
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c := newContext(ctxId, v)
|
c := newContext(ctxID, pageRoute, v)
|
||||||
pageInitFn(c)
|
pageInitFn(c)
|
||||||
return c
|
v.registerCtx(ctxID, c)
|
||||||
|
v.logDebug(nil, "devmode restored ctx reg=%v", v.contextRegistry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Via application with default configuration.
|
// New creates a new Via application with default configuration.
|
||||||
@@ -282,14 +270,10 @@ func New() *V {
|
|||||||
v.mux.HandleFunc("GET /_sse", func(w http.ResponseWriter, r *http.Request) {
|
v.mux.HandleFunc("GET /_sse", func(w http.ResponseWriter, r *http.Request) {
|
||||||
var sigs map[string]any
|
var sigs map[string]any
|
||||||
_ = datastar.ReadSignals(r, &sigs)
|
_ = datastar.ReadSignals(r, &sigs)
|
||||||
if v.cfg.DevMode && len(v.contextRegistry) == 0 {
|
|
||||||
restoredC := v.restoreCtx()
|
|
||||||
if restoredC != nil {
|
|
||||||
restoredC.injectSignals(sigs)
|
|
||||||
v.registerCtx(restoredC.id, restoredC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cID, _ := sigs["via-ctx"].(string)
|
cID, _ := sigs["via-ctx"].(string)
|
||||||
|
if v.cfg.DevMode && len(v.contextRegistry) == 0 {
|
||||||
|
v.Restore()
|
||||||
|
}
|
||||||
c, err := v.getCtx(cID)
|
c, err := v.getCtx(cID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.logErr(nil, "failed to render page: %v", err)
|
v.logErr(nil, "failed to render page: %v", err)
|
||||||
@@ -299,7 +283,6 @@ func New() *V {
|
|||||||
v.logDebug(c, "SSE connection established")
|
v.logDebug(c, "SSE connection established")
|
||||||
if v.cfg.DevMode {
|
if v.cfg.DevMode {
|
||||||
c.Sync()
|
c.Sync()
|
||||||
v.persistCtx(c)
|
|
||||||
} else {
|
} else {
|
||||||
c.SyncSignals()
|
c.SyncSignals()
|
||||||
}
|
}
|
||||||
@@ -312,16 +295,6 @@ func New() *V {
|
|||||||
var sigs map[string]any
|
var sigs map[string]any
|
||||||
_ = datastar.ReadSignals(r, &sigs)
|
_ = datastar.ReadSignals(r, &sigs)
|
||||||
cID, _ := sigs["via-ctx"].(string)
|
cID, _ := sigs["via-ctx"].(string)
|
||||||
active_ctx_count := 0
|
|
||||||
inactive_ctx_count := 0
|
|
||||||
for _, c := range v.contextRegistry {
|
|
||||||
if c.sse != nil {
|
|
||||||
active_ctx_count++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
inactive_ctx_count++
|
|
||||||
}
|
|
||||||
v.logDebug(nil, "active_ctx_count=%d inactive_ctx_count=%d", active_ctx_count, inactive_ctx_count)
|
|
||||||
c, err := v.getCtx(cID)
|
c, err := v.getCtx(cID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.logErr(nil, "action '%s' failed: %v", actionID, err)
|
v.logErr(nil, "action '%s' failed: %v", actionID, err)
|
||||||
@@ -343,7 +316,6 @@ func New() *V {
|
|||||||
v.logDebug(c, "signals=%v", sigs)
|
v.logDebug(c, "signals=%v", sigs)
|
||||||
c.injectSignals(sigs)
|
c.injectSignals(sigs)
|
||||||
actionFn()
|
actionFn()
|
||||||
|
|
||||||
})
|
})
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user