mirror of
https://github.com/fiatjaf/vnak.git
synced 2026-10-05 15:38:23 +00:00
build tab working.
This commit is contained in:
@@ -3,14 +3,29 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strings"
|
||||||
|
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
"fiatjaf.com/nostr/schema"
|
"fiatjaf.com/nostr/schema"
|
||||||
qt "github.com/mappu/miqt/qt6"
|
qt "github.com/mappu/miqt/qt6"
|
||||||
|
"github.com/mappu/miqt/qt6/mainthread"
|
||||||
)
|
)
|
||||||
|
|
||||||
type buildVars struct {
|
type buildVars struct {
|
||||||
kindsCombo *qt.QComboBox
|
tab *qt.QWidget
|
||||||
|
kindsList *qt.QListWidget
|
||||||
|
items []kindItem
|
||||||
|
selected *kindItem
|
||||||
|
|
||||||
|
// dynamic form elements
|
||||||
|
descLabel *qt.QLabel
|
||||||
|
formLayout *qt.QVBoxLayout
|
||||||
|
formWidget *qt.QWidget
|
||||||
|
|
||||||
|
tagSections []*tagSection
|
||||||
|
|
||||||
|
contentEdit *qt.QTextEdit
|
||||||
|
contentLabel *qt.QLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
type kindItem struct {
|
type kindItem struct {
|
||||||
@@ -18,88 +33,320 @@ type kindItem struct {
|
|||||||
schema.KindSchema
|
schema.KindSchema
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tagSection struct {
|
||||||
|
isMultiple bool
|
||||||
|
isBoolean bool
|
||||||
|
isRequired bool
|
||||||
|
|
||||||
|
sectionBox *qt.QHBoxLayout
|
||||||
|
label *qt.QLabel
|
||||||
|
rowsBox *qt.QVBoxLayout
|
||||||
|
rows []*qt.QHBoxLayout
|
||||||
|
edits [][]*qt.QLineEdit
|
||||||
|
}
|
||||||
|
|
||||||
var build = &buildVars{}
|
var build = &buildVars{}
|
||||||
|
|
||||||
func setupBuildTab() *qt.QWidget {
|
func setupBuildTab() *qt.QWidget {
|
||||||
tab := qt.NewQWidget(window.QWidget)
|
build.tab = qt.NewQWidget(window.QWidget)
|
||||||
layout := qt.NewQVBoxLayout2()
|
layout := qt.NewQVBoxLayout2()
|
||||||
tab.SetLayout(layout.QLayout)
|
build.tab.SetLayout(layout.QLayout)
|
||||||
|
|
||||||
// label
|
// label
|
||||||
label := qt.NewQLabel2()
|
label := qt.NewQLabel2()
|
||||||
label.SetText("select a kind to build an event:")
|
label.SetText("select a kind to build an event:")
|
||||||
layout.AddWidget(label.QWidget)
|
layout.AddWidget(label.QWidget)
|
||||||
|
|
||||||
// dropdown
|
// search/filter input
|
||||||
build.kindsCombo = qt.NewQComboBox(tab)
|
searchInput := qt.NewQLineEdit(build.tab)
|
||||||
layout.AddWidget(build.kindsCombo.QWidget)
|
searchInput.SetPlaceholderText("filter kinds...")
|
||||||
|
layout.AddWidget(searchInput.QWidget)
|
||||||
|
|
||||||
// load kinds
|
// list widget for kind selection
|
||||||
loadKinds()
|
build.kindsList = qt.NewQListWidget(build.tab)
|
||||||
|
build.kindsList.SetMaximumHeight(100)
|
||||||
|
layout.AddWidget(build.kindsList.QWidget)
|
||||||
|
|
||||||
return tab
|
// filter list items as user types
|
||||||
|
searchInput.OnTextChanged(func(text string) {
|
||||||
|
filterText := strings.ToLower(text)
|
||||||
|
for i := 0; i < build.kindsList.Count(); i++ {
|
||||||
|
item := build.kindsList.Item(i)
|
||||||
|
itemText := strings.ToLower(item.Text())
|
||||||
|
// TODO: instead of strings.Contains() use levenshtein distance fuzz matching
|
||||||
|
build.kindsList.SetRowHidden(i, !strings.Contains(itemText, filterText))
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
func loadKinds() {
|
// description label
|
||||||
sch, err := schema.FetchSchemaFromURL(schema.DefaultSchemaURL)
|
build.descLabel = qt.NewQLabel2()
|
||||||
if err != nil {
|
build.descLabel.SetWordWrap(true)
|
||||||
fmt.Println("error loading schema:", err)
|
layout.AddWidget(build.descLabel.QWidget)
|
||||||
setStatus(tabs.build, "error loading schema: %s", err)
|
|
||||||
|
// form container for dynamic tag inputs
|
||||||
|
build.formWidget = qt.NewQWidget(build.tab)
|
||||||
|
build.formLayout = qt.NewQVBoxLayout2()
|
||||||
|
build.formWidget.SetLayout(build.formLayout.QLayout)
|
||||||
|
layout.AddWidget(build.formWidget)
|
||||||
|
|
||||||
|
// content input
|
||||||
|
build.contentLabel = qt.NewQLabel2()
|
||||||
|
build.contentLabel.SetText("content:")
|
||||||
|
build.contentLabel.SetVisible(false)
|
||||||
|
layout.AddWidget(build.contentLabel.QWidget)
|
||||||
|
build.contentEdit = qt.NewQTextEdit(build.tab)
|
||||||
|
build.contentEdit.SetVisible(false)
|
||||||
|
build.contentEdit.SetMaximumHeight(40)
|
||||||
|
layout.AddWidget(build.contentEdit.QWidget)
|
||||||
|
|
||||||
|
// button to continue to event tab
|
||||||
|
buttonHBox := qt.NewQHBoxLayout2()
|
||||||
|
continueButton := qt.NewQPushButton5("event "+ARROW, build.tab)
|
||||||
|
continueButton.SetVisible(false)
|
||||||
|
buttonHBox.AddWidget(continueButton.QWidget)
|
||||||
|
buttonHBox.AddStretch()
|
||||||
|
layout.AddLayout(buttonHBox.QLayout)
|
||||||
|
|
||||||
|
continueButton.OnClicked(func() {
|
||||||
|
eventBuilt := build.buildEvent()
|
||||||
|
|
||||||
|
// TODO: check for missing tags in event
|
||||||
|
missing := []string{}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
setStatus(tabs.build, "missing required tags: %s", strings.Join(missing, ", "))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]kindItem, 0, len(sch.Kinds))
|
// TODO: check each tag to see if it has all the necessary items and they match each of the types
|
||||||
|
// ('constrained', 'url', 'relay', 'id', 'addr', 'pubkey', 'timestamp', 'kind', 'giturl'. 'json', 'hex')
|
||||||
|
// ...
|
||||||
|
|
||||||
|
event.populate(eventBuilt)
|
||||||
|
tabWidget.SetCurrentIndex(tabs.event)
|
||||||
|
})
|
||||||
|
|
||||||
|
// spacer at bottom
|
||||||
|
layout.AddStretch()
|
||||||
|
|
||||||
|
// load kinds
|
||||||
|
go func() {
|
||||||
|
sch, err := schema.FetchSchemaFromURL("https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/refs/heads/master/schema.yaml")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("error loading schema:", err)
|
||||||
|
mainthread.Wait(func() {
|
||||||
|
setStatus(tabs.build, "error loading schema: %s", err)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
build.items = make([]kindItem, 0, len(sch.Kinds))
|
||||||
for k, v := range sch.Kinds {
|
for k, v := range sch.Kinds {
|
||||||
num := -1
|
num := -1
|
||||||
fmt.Sscanf(k, "%d", &num)
|
fmt.Sscanf(k, "%d", &num)
|
||||||
items = append(items, kindItem{kind: uint16(num), KindSchema: v})
|
if v.InUse {
|
||||||
|
build.items = append(build.items, kindItem{kind: uint16(num), KindSchema: v})
|
||||||
}
|
}
|
||||||
slices.SortFunc(items, func(a, b kindItem) int { return int(a.kind - b.kind) })
|
|
||||||
|
|
||||||
model := qt.NewQStandardItemModel2(len(items)+2, 2)
|
|
||||||
inUseCategory := qt.NewQStandardItem2("common")
|
|
||||||
inUseCategory.SetFlags(inUseCategory.Flags() ^ qt.ItemIsSelectable) // remove selectable flag
|
|
||||||
inUseCategory.SetData(qt.NewQVariant(), int(qt.FontRole)) // bold font
|
|
||||||
model.AppendRowWithItem(inUseCategory)
|
|
||||||
|
|
||||||
notInUseCategory := qt.NewQStandardItem2("weird")
|
|
||||||
notInUseCategory.SetFlags(inUseCategory.Flags() ^ qt.ItemIsSelectable) // remove selectable flag
|
|
||||||
notInUseCategory.SetData(qt.NewQVariant(), int(qt.FontRole)) // bold font
|
|
||||||
model.AppendRowWithItem(notInUseCategory)
|
|
||||||
|
|
||||||
for _, item := range items {
|
|
||||||
qitem := []*qt.QStandardItem{
|
|
||||||
qt.NewQStandardItem2(strconv.Itoa(int(item.kind))),
|
|
||||||
qt.NewQStandardItem2(item.Description),
|
|
||||||
}
|
}
|
||||||
if item.InUse {
|
slices.SortFunc(build.items, func(a, b kindItem) int { return int(a.kind) - int(b.kind) })
|
||||||
inUseCategory.AppendRow(qitem)
|
|
||||||
|
mainthread.Wait(func() {
|
||||||
|
// populate list widget with items
|
||||||
|
for _, item := range build.items {
|
||||||
|
build.kindsList.AddItem(fmt.Sprintf("%d - %s", item.kind, item.Description))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle item selection
|
||||||
|
build.kindsList.OnItemClicked(func(li *qt.QListWidgetItem) {
|
||||||
|
row := build.kindsList.Row(li)
|
||||||
|
if row >= 0 && row < len(build.items) {
|
||||||
|
kindItem := build.items[row]
|
||||||
|
build.selected = &kindItem
|
||||||
|
item := kindItem.KindSchema
|
||||||
|
|
||||||
|
build.clearForm()
|
||||||
|
|
||||||
|
// show description
|
||||||
|
build.descLabel.SetText(item.Description)
|
||||||
|
|
||||||
|
// generate tag inputs based on schema
|
||||||
|
for _, tagSpec := range item.Tags {
|
||||||
|
isMultiple := slices.Contains(item.Multiple, tagSpec.Name)
|
||||||
|
if tagSpec.Prefix != "" {
|
||||||
|
isMultiple = isMultiple || slices.Contains(item.Multiple, tagSpec.Prefix)
|
||||||
|
}
|
||||||
|
build.addTag(&tagSpec, slices.Contains(item.Required, tagSpec.Name), isMultiple)
|
||||||
|
}
|
||||||
|
|
||||||
|
// show content input if the kind has content
|
||||||
|
hasContent := item.Content.Type != "empty"
|
||||||
|
build.contentLabel.SetVisible(hasContent)
|
||||||
|
build.contentEdit.SetVisible(hasContent)
|
||||||
|
if hasContent {
|
||||||
|
build.contentLabel.SetText(fmt.Sprintf("content (%s):", item.Content.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
continueButton.SetVisible(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
return build.tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buildVars) addTag(tagSpec *schema.TagSpec, required bool, isMultiple bool) {
|
||||||
|
ts := &tagSection{
|
||||||
|
isMultiple: isMultiple,
|
||||||
|
isBoolean: tagSpec.Next == nil,
|
||||||
|
isRequired: required,
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.sectionBox = qt.NewQHBoxLayout2()
|
||||||
|
b.formLayout.AddLayout(ts.sectionBox.QLayout)
|
||||||
|
|
||||||
|
// label for the tag
|
||||||
|
labelText := tagSpec.Name
|
||||||
|
if required {
|
||||||
|
labelText += " (required)"
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.label = qt.NewQLabel2()
|
||||||
|
ts.label.SetText(labelText + ":")
|
||||||
|
ts.label.SetMinimumWidth(80)
|
||||||
|
ts.sectionBox.AddWidget(ts.label.QWidget)
|
||||||
|
|
||||||
|
b.tagSections = append(b.tagSections, ts)
|
||||||
|
|
||||||
|
// input for the tag value
|
||||||
|
if tagSpec.Next != nil {
|
||||||
|
vbox := qt.NewQVBoxLayout2()
|
||||||
|
ts.rowsBox = vbox
|
||||||
|
ts.sectionBox.AddLayout(vbox.Layout())
|
||||||
|
build.addTagRow(0, ts, tagSpec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buildVars) addTagRow(i int, ts *tagSection, tagSpec *schema.TagSpec) {
|
||||||
|
hbox := qt.NewQHBoxLayout2()
|
||||||
|
ts.rowsBox.AddLayout(hbox.QLayout)
|
||||||
|
ts.rows = append(ts.rows, hbox)
|
||||||
|
ts.edits = append(ts.edits, make([]*qt.QLineEdit, 0, 3))
|
||||||
|
|
||||||
|
for curr := tagSpec.Next; curr != nil; curr = curr.Next {
|
||||||
|
// input for the tag value
|
||||||
|
edit := qt.NewQLineEdit(b.tab)
|
||||||
|
switch curr.Type {
|
||||||
|
case "constrained":
|
||||||
|
edit.SetPlaceholderText("\"" + strings.Join(curr.Either, "\" or \"") + "\"")
|
||||||
|
default:
|
||||||
|
edit.SetPlaceholderText(curr.Type)
|
||||||
|
}
|
||||||
|
hbox.AddWidget(edit.QWidget)
|
||||||
|
ts.edits[i] = append(ts.edits[i], edit)
|
||||||
|
|
||||||
|
edit.OnTextChanged(func(text string) {
|
||||||
|
if !ts.isMultiple {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(text) != "" {
|
||||||
|
// if this is the last row and it has content and this tag accepts multiple values and add a new row
|
||||||
|
if i == len(ts.rowsBox.Children())-1 {
|
||||||
|
b.addTagRow(i+1, ts, tagSpec)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
notInUseCategory.AppendRow(qitem)
|
// now if this is the second to last row and the last row and it is fully empty we remove the last
|
||||||
|
if len(ts.rows) >= 2 && i == len(ts.rows)-2 {
|
||||||
|
shouldDelete := true
|
||||||
|
// if all inputs are empty in the last two rows we will delete
|
||||||
|
deletecheck:
|
||||||
|
for r := len(ts.rows) - 2; r < len(ts.edits); r++ {
|
||||||
|
for _, edit := range ts.edits[r] {
|
||||||
|
if strings.TrimSpace(edit.Text()) != "" {
|
||||||
|
shouldDelete = false
|
||||||
|
break deletecheck
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy := qt.NewQSortFilterProxyModel()
|
if shouldDelete {
|
||||||
proxy.SetSourceModel(model.QAbstractItemModel)
|
last := ts.rows[len(ts.rows)-1]
|
||||||
proxy.SetFilterCaseSensitivity(qt.CaseInsensitive)
|
for _, edit := range ts.edits[len(ts.rows)-1] {
|
||||||
build.kindsCombo.SetModel(proxy.QAbstractItemModel)
|
last.RemoveWidget(edit.QWidget)
|
||||||
build.kindsCombo.SetEditable(true)
|
edit.DeleteLater()
|
||||||
|
}
|
||||||
|
ts.edits = ts.edits[0 : len(ts.edits)-1]
|
||||||
|
|
||||||
tableView := qt.NewQAbstractItemView2()
|
ts.rowsBox.RemoveItem(last.QLayoutItem)
|
||||||
tableView.SetSelectionBehavior(qt.QAbstractItemView__SelectRows)
|
last.DeleteLater()
|
||||||
tableView.SetSelectionMode(qt.QAbstractItemView__SingleSelection)
|
ts.rows = ts.rows[0 : len(ts.rows)-1]
|
||||||
build.kindsCombo.SetView(tableView)
|
}
|
||||||
|
}
|
||||||
build.kindsCombo.OnCurrentIndexChanged(func(index int) {
|
}
|
||||||
sourceIndex := proxy.MapToSource(proxy.Index(index, 0, qt.NewQModelIndex()))
|
|
||||||
actualIndex := sourceIndex.Row()
|
|
||||||
selectedItem := items[actualIndex]
|
|
||||||
fmt.Println("selected", selectedItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
build.kindsCombo.OnEditTextChanged(func(text string) {
|
|
||||||
fmt.Println("########", text)
|
|
||||||
|
|
||||||
proxy.SetFilterFixedString(text)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buildVars) buildEvent() nostr.Event {
|
||||||
|
base := nostr.Event{
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Kind: nostr.Kind(b.selected.kind),
|
||||||
|
Tags: make(nostr.Tags, 0, 3),
|
||||||
|
Content: b.contentEdit.ToPlainText(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// of all the hboxes
|
||||||
|
for _, ts := range b.tagSections {
|
||||||
|
// if the second element of an hbox is a vbox that means it is a top-level hbox
|
||||||
|
// get tag name from the hbox
|
||||||
|
tagName := strings.TrimSuffix(
|
||||||
|
strings.SplitN(ts.label.Text(), " ", 2)[0],
|
||||||
|
":",
|
||||||
|
)
|
||||||
|
|
||||||
|
for e, edits := range ts.edits {
|
||||||
|
tag := make(nostr.Tag, 1, 4)
|
||||||
|
tag[0] = tagName
|
||||||
|
|
||||||
|
hasValue := false
|
||||||
|
for _, edit := range edits {
|
||||||
|
value := strings.TrimSpace(edit.Text())
|
||||||
|
tag = append(tag, value)
|
||||||
|
if value != "" {
|
||||||
|
hasValue = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ts.isBoolean || (ts.isRequired && e == 0) || hasValue {
|
||||||
|
base.Tags = append(base.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buildVars) clearForm() {
|
||||||
|
// clear all tag layouts and inputs
|
||||||
|
for _, ts := range b.tagSections {
|
||||||
|
ts.sectionBox.RemoveWidget(ts.label.QWidget)
|
||||||
|
ts.label.DeleteLater()
|
||||||
|
|
||||||
|
for r, row := range ts.rows {
|
||||||
|
for _, edit := range ts.edits[r] {
|
||||||
|
row.RemoveWidget(edit.QWidget)
|
||||||
|
edit.DeleteLater()
|
||||||
|
}
|
||||||
|
ts.rowsBox.RemoveItem(row.QLayoutItem)
|
||||||
|
row.DeleteLater()
|
||||||
|
}
|
||||||
|
ts.rowsBox.DeleteLater()
|
||||||
|
|
||||||
|
b.formLayout.RemoveItem(ts.sectionBox.QLayoutItem)
|
||||||
|
ts.sectionBox.DeleteLater()
|
||||||
|
}
|
||||||
|
b.tagSections = b.tagSections[:0]
|
||||||
|
|
||||||
|
// but keep the content just in case
|
||||||
|
// b.contentEdit.SetPlainText("")
|
||||||
|
}
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ func (bunker *bunkerVars) startBunker() {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
authorized := slices.Contains(bunker.config.AuthorizedKeys, from) || slices.Contains([]string{}, secret) // TODO: authorized secrets
|
authorized := slices.Contains(bunker.config.AuthorizedKeys, from) || slices.Contains([]string{}, secret)
|
||||||
if authorized {
|
if authorized {
|
||||||
if harmless {
|
if harmless {
|
||||||
bunker.log("harmless request from %s", from.Hex())
|
bunker.log("harmless request from %s", from.Hex())
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func setupEventTab() *qt.QWidget {
|
|||||||
event.outputEdit = qt.NewQTextEdit(event.tab)
|
event.outputEdit = qt.NewQTextEdit(event.tab)
|
||||||
event.outputEdit.SetReadOnly(true)
|
event.outputEdit.SetReadOnly(true)
|
||||||
outputHBox.AddWidget(event.outputEdit.QWidget)
|
outputHBox.AddWidget(event.outputEdit.QWidget)
|
||||||
copyButton := qt.NewQPushButton5(ARROW, event.tab)
|
copyButton := qt.NewQPushButton5("manual edit "+ARROW, event.tab)
|
||||||
outputHBox.AddWidget(copyButton.QWidget)
|
outputHBox.AddWidget(copyButton.QWidget)
|
||||||
copyButton.OnClicked(func() {
|
copyButton.OnClicked(func() {
|
||||||
tabWidget.SetCurrentIndex(tabs.paste)
|
tabWidget.SetCurrentIndex(tabs.paste)
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ func setupReqTab() *qt.QWidget {
|
|||||||
buttonsHBox := qt.NewQHBoxLayout2()
|
buttonsHBox := qt.NewQHBoxLayout2()
|
||||||
dlayout.AddLayout(buttonsHBox.QLayout)
|
dlayout.AddLayout(buttonsHBox.QLayout)
|
||||||
|
|
||||||
editButton := qt.NewQPushButton5(ARROW+" paste", dialog.QWidget)
|
editButton := qt.NewQPushButton5("manual edit "+ARROW, dialog.QWidget)
|
||||||
editButton.OnClicked(func() {
|
editButton.OnClicked(func() {
|
||||||
paste.inputEdit.SetPlainText(string(pretty))
|
paste.inputEdit.SetPlainText(string(pretty))
|
||||||
tabWidget.SetCurrentIndex(tabs.paste)
|
tabWidget.SetCurrentIndex(tabs.paste)
|
||||||
@@ -269,7 +269,7 @@ func setupReqTab() *qt.QWidget {
|
|||||||
})
|
})
|
||||||
buttonsHBox.AddWidget(editButton.QWidget)
|
buttonsHBox.AddWidget(editButton.QWidget)
|
||||||
|
|
||||||
publishButton := qt.NewQPushButton5(ARROW+" event", dialog.QWidget)
|
publishButton := qt.NewQPushButton5("structured edit "+ARROW, dialog.QWidget)
|
||||||
publishButton.OnClicked(func() {
|
publishButton.OnClicked(func() {
|
||||||
event.populate(evt)
|
event.populate(evt)
|
||||||
tabWidget.SetCurrentIndex(tabs.event)
|
tabWidget.SetCurrentIndex(tabs.event)
|
||||||
|
|||||||
Reference in New Issue
Block a user