From e7cfd1c0c51cb46136727e4c751d8fb6f438a5ab Mon Sep 17 00:00:00 2001 From: David Florness Date: Sun, 30 May 2021 12:10:13 -0400 Subject: [PATCH] Use syncer hooks instead of recurring updates for TUI --- cmd/tallyard/main.go | 27 +++-- election/map.go | 43 ++------ election/room.go | 44 ++++++++ matrix/store.go | 20 +++- ui/tui.go | 252 +++++++++++++++++++++++++++---------------- 5 files changed, 249 insertions(+), 137 deletions(-) create mode 100644 election/room.go diff --git a/cmd/tallyard/main.go b/cmd/tallyard/main.go index 8210086..5e5c7cd 100644 --- a/cmd/tallyard/main.go +++ b/cmd/tallyard/main.go @@ -47,17 +47,17 @@ func main() { setDeviceName(client, authInfo.DeviceID) // setup the elections store - elections, err := getElections(authInfo.UserID) + electionsMap, err := getElections(authInfo.UserID) if err != nil { panic(err) } - client.Store = matrix.NewTallyardStore(elections) - defer elections.Save() + client.Store = matrix.NewTallyardStore(electionsMap) + defer electionsMap.Save() syncer := client.Syncer.(*mautrix.DefaultSyncer) syncer.OnEvent(debugEventHook) syncer.OnEvent(client.Store.(*matrix.TallyardStore).UpdateState) - elections.SetupEventHooks(client, syncer) + electionsMap.SetupEventHooks(client, syncer) go func() { res, err := client.CreateFilter(electionFilter) @@ -72,17 +72,17 @@ func main() { }() // select a room - roomID, err := ui.RoomListTUI(client, elections) + room, err := ui.RoomListTUI(client.Store.(*matrix.TallyardStore), client.UserID, syncer) if err != nil { panic(err) } - if roomID == "" { + if room == nil { // no room selected; user likely hit C-c return } // select an election - el, err := ui.RoomTUI(client, roomID, elections) + el, err := ui.RoomTUI(client, room, electionsMap, syncer) if err != nil { panic(err) } @@ -92,7 +92,7 @@ func main() { } // wait for election to start if needed - err = ui.ElectionWaitTUI(client, el, elections.EventStore) + err = ui.ElectionWaitTUI(client, el, electionsMap.EventStore) if err != nil { panic(err) } @@ -133,7 +133,7 @@ func main() { // send evals if needed if el.LocalVoter != nil && el.LocalVoter.EvalsID == nil { - err = el.SendEvals(client, elections.EventStore) + err = el.SendEvals(client, electionsMap.EventStore) if err != nil { panic(err) } @@ -147,7 +147,7 @@ func main() { time.Sleep(time.Millisecond * 200) } } - err = el.SendSum(client, elections.EventStore) + err = el.SendSum(client, electionsMap.EventStore) if err != nil { panic(err) } @@ -174,7 +174,12 @@ var electionFilter = &mautrix.Filter{ }, State: mautrix.FilterPart{ LazyLoadMembers: true, - Types: []event.Type{event.StateRoomName}, + Types: []event.Type{ + event.StateCreate, + event.StateEncryption, + event.StateMember, + event.StateRoomName, + }, }, Timeline: mautrix.FilterPart{ LazyLoadMembers: true, diff --git a/election/map.go b/election/map.go index 1280af2..c158558 100644 --- a/election/map.go +++ b/election/map.go @@ -3,12 +3,9 @@ package election import ( "encoding/json" "fmt" - "sort" "sync" - "time" log "github.com/sirupsen/logrus" - "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" ) @@ -19,22 +16,17 @@ type ElectionsMap struct { // telling the user to update their file to the current version. The // version in the file should only be different if there has been a // breaking change to the elections map format. - Version int `json:"version"` + Version int `json:"version"` // Maps election create event IDs to the corresponding election. - Elections map[id.EventID]*Election `json:"elections"` + Elections map[id.EventID]*Election `json:"elections"` // See EventStore doc for explanation - EventStore *EventStore `json:"event_store,omitempty"` + EventStore *EventStore `json:"event_store,omitempty"` // State store - NextBatch string `json:"next_batch"` - Rooms map[id.RoomID]*mautrix.Room `json:"rooms"` - UserID id.UserID `json:"user_id"` - // Maps room to a list of the room's elections, reverse sorted by - // CreationTimestamp (i.e. newest to oldest). Here for convenience for - // GUIs. Ltime represents the latest time L was updated. - L map[id.RoomID][]*Election `json:"-"` - Ltime time.Time `json:"-"` + NextBatch string `json:"next_batch"` + Rooms map[id.RoomID]*Room `json:"rooms"` + UserID id.UserID `json:"user_id"` // hook given by parent code to save elections (e.g. to disk) - save func(*ElectionsMap) `json:"-"` + save func(*ElectionsMap) `json:"-"` } const electionsMapVersion = 6 @@ -43,9 +35,7 @@ func NewElectionsMap(userID id.UserID, save func(*ElectionsMap)) *ElectionsMap { return &ElectionsMap{ Version: electionsMapVersion, Elections: make(map[id.EventID]*Election), - L: make(map[id.RoomID][]*Election), - Ltime: time.Now(), - Rooms: make(map[id.RoomID]*mautrix.Room), + Rooms: make(map[id.RoomID]*Room), UserID: userID, save: save, } @@ -66,8 +56,6 @@ func ReadElectionsMapFrom(jsonBytes []byte, userID id.UserID, save func(*Electio return nil, fmt.Errorf("user IDs don't match") } // set runtime attributes for elections - em.L = make(map[id.RoomID][]*Election, 0) - em.Ltime = time.Now() em.save = save storedElections := em.Elections em.Elections = make(map[id.EventID]*Election, len(storedElections)) @@ -84,6 +72,7 @@ func (em *ElectionsMap) UnmarshalJSON(b []byte) error { return err } for _, room := range em.Rooms { + room.electionsMap = em for eventType, events := range room.State { for _, evt := range events { if err = evt.Content.ParseRaw(eventType); err != nil { @@ -118,20 +107,6 @@ func (em *ElectionsMap) AddElection(el *Election) { defer el.Unlock() em.Elections[el.CreateEvt.ID] = el el.Save = em.Save - em.insortElection(el.CreateEvt.ID, el) -} - -func (em *ElectionsMap) insortElection(createID id.EventID, el *Election) { - list := em.L[el.RoomID] - i := sort.Search(len(list), func(i int) bool { - return list[i].CreateEvt.Timestamp < el.CreateEvt.Timestamp - }) - newList := make([]*Election, len(list)+1) - copy(newList[:i], list[:i]) - newList[i] = el - copy(newList[i+1:], list[i:]) - em.L[el.RoomID] = newList - em.Ltime = time.Now() } func (em *ElectionsMap) SetEventStore(eventStore *EventStore) { diff --git a/election/room.go b/election/room.go new file mode 100644 index 0000000..124ab5b --- /dev/null +++ b/election/room.go @@ -0,0 +1,44 @@ +package election + +import ( + "maunium.net/go/mautrix" +) + +type Room struct { + *mautrix.Room + electionsMap *ElectionsMap `json:"-"` // ElectionsMap contains rooms; we don't want infinite recursion +} + +func NewRoom(mroom *mautrix.Room, electionsMap *ElectionsMap) *Room { + return &Room{ + Room: mroom, + electionsMap: electionsMap, + } +} + +func (room *Room) HasElections() bool { + room.electionsMap.RLock() + defer room.electionsMap.RUnlock() + + for _, el := range room.electionsMap.Elections { + if el.RoomID == room.ID { + return true + } + } + + return false +} + +func (room *Room) GetElections() []*Election { + room.electionsMap.RLock() + defer room.electionsMap.RUnlock() + + var elections []*Election + for _, el := range room.electionsMap.Elections { + if el.RoomID == room.ID { + elections = append(elections, el) + } + } + + return elections +} diff --git a/matrix/store.go b/matrix/store.go index 70b3d6d..6046418 100644 --- a/matrix/store.go +++ b/matrix/store.go @@ -56,10 +56,20 @@ func (s *TallyardStore) LoadNextBatch(userID id.UserID) string { func (s *TallyardStore) SaveRoom(room *mautrix.Room) { s.electionsMap.Lock() defer s.electionsMap.Unlock() - s.electionsMap.Rooms[room.ID] = room + s.electionsMap.Rooms[room.ID] = election.NewRoom(room, s.electionsMap) } func (s *TallyardStore) LoadRoom(roomID id.RoomID) *mautrix.Room { + s.electionsMap.RLock() + defer s.electionsMap.RUnlock() + if room, exists := s.electionsMap.Rooms[roomID]; exists { + return room.Room + } else { + return nil + } +} + +func (s *TallyardStore) LoadElectionsRoom(roomID id.RoomID) *election.Room { s.electionsMap.RLock() defer s.electionsMap.RUnlock() return s.electionsMap.Rooms[roomID] @@ -76,3 +86,11 @@ func (s *TallyardStore) UpdateState(_ mautrix.EventSource, evt *event.Event) { } room.UpdateState(evt) } + +func (s *TallyardStore) GetElectionRooms() []*election.Room { + var rooms []*election.Room + for _, room := range s.electionsMap.Rooms { + rooms = append(rooms, room) + } + return rooms +} diff --git a/ui/tui.go b/ui/tui.go index bf94aa1..c012b58 100644 --- a/ui/tui.go +++ b/ui/tui.go @@ -16,108 +16,181 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "tallyard.xyz/election" + "tallyard.xyz/matrix" ) -func RoomListTUI(client *mautrix.Client, elections *election.ElectionsMap) (roomID id.RoomID, err error) { +// displays a TUI of rooms that the user may choose from. This presumes that the +// store is already properly saving rooms +func RoomListTUI(store *matrix.TallyardStore, localUserID id.UserID, syncer mautrix.ExtensibleSyncer) (*election.Room, error) { app := newTallyardApplication() - resp, err := client.JoinedRooms() - if err != nil { - return - } - // sorts rooms by name (putting rooms without names at the end and - // putting all rooms that have elections at the top) - cmpRooms := func(i, j id.RoomID) bool { - roomI := client.Store.LoadRoom(i) - roomJ := client.Store.LoadRoom(j) - if roomJ == nil { - return true - } - if roomI == nil { - return false - } - elections.RLock() - _, iHasElections := elections.L[i] - _, jHasElections := elections.L[j] - elections.RUnlock() - if iHasElections && !jHasElections { - return true + list := tview.NewList().AddItem("syncing...", "", 0, nil) + list.SetTitle("Select a room").SetBorder(true) + var rooms []*roomWithTitle + + update := func(room *election.Room) { + // room must be encrypted; see + // https://todo.hnitbjorg.xyz/~edwargix/tallyard/13 + if room.GetStateEvent(event.StateEncryption, "") == nil { + return } - if jHasElections && !iHasElections { - return false + // local user must be in room + if room.GetMembershipState(localUserID) != event.MembershipJoin { + return } - var nameI, nameJ string - if nameEvt, ok := roomJ.State[event.StateRoomName][""]; ok { - nameJ = nameEvt.Content.AsRoomName().Name + + var title string + if nameEvt := room.GetStateEvent(event.StateRoomName, ""); nameEvt != nil { + title = nameEvt.Content.AsRoomName().Name } else { - return true + var members []string + for userID := range room.State[event.StateMember] { + if room.GetMembershipState(id.UserID(userID)) != event.MembershipJoin { + continue + } + if userID == localUserID.String() { + continue + } + i := sort.SearchStrings(members, userID) + members = append(members[:i], append([]string{userID}, members[i:]...)...) + } + if len(members) == 0 { + // e.g. a DM where the other person hasn't joined + return + } + title = members[0] + if len(members) > 1 { + for i, mem := range members[1:] { + title += ", " + title += mem + if i > 0 { // list up to three + break + } + } + if len(members) > 3 { + title += "..." + } + } } - if nameEvt, ok := roomI.State[event.StateRoomName][""]; ok { - nameI = nameEvt.Content.AsRoomName().Name - } else { - return false + n := len(room.GetElections()) + if n > 0 { + title = fmt.Sprintf("%s (%d elections)", title, n) } - return nameI < nameJ - } - list := tview.NewList() - list.SetTitle("Select a room").SetBorder(true) - update := func() { - // sort rooms - sort.Slice(resp.JoinedRooms, func(i, j int) bool { - return cmpRooms(resp.JoinedRooms[i], resp.JoinedRooms[j]) - }) - for i, roomID := range resp.JoinedRooms { - if list.GetItemCount() <= i { - list.AddItem(roomID.String(), roomID.String(), 0, nil) - } - room := client.Store.LoadRoom(roomID) - if room == nil { + + for i, r := range rooms { + if r.ID != room.ID { continue } - if roomNameEvent, ok := room.State[event.StateRoomName][""]; ok { - name := roomNameEvent.Content.AsRoomName().Name - electionCount := len(elections.L[roomID]) - if electionCount > 0 { - name = fmt.Sprintf("%s (%d elections)", name, electionCount) - } - elections.RLock() - list.SetItemText(i, name, roomID.String()) - elections.RUnlock() + if i+1 < len(rooms) { + rooms = append(rooms[:i], rooms[i+1:]...) + } else { + rooms = rooms[:i] + } + break + } + rwt := &roomWithTitle{room, title} + i := sort.Search(len(rooms), func(i int) bool { + return rwt.cmp(rooms[i]) + }) + rooms = append(rooms[:i], append([]*roomWithTitle{rwt}, rooms[i:]...)...) + + for i, room := range rooms { + if i < list.GetItemCount() { + list.SetItemText(i, room.title, room.ID.String()) + } else { + list.AddItem(room.title, room.ID.String(), 0, nil) } } } - go func() { - for app.alive { - app.QueueUpdateDraw(update) - time.Sleep(300 * time.Millisecond) + + cb := func(_ mautrix.EventSource, evt *event.Event) { + if app.alive { + room := store.LoadElectionsRoom(evt.RoomID) + if room == nil { + return + } + app.QueueUpdateDraw(func() { + update(room) + }) } - }() + } + + syncer.OnEventType(election.CreateElectionMessage, cb) + syncer.OnEventType(event.StateEncryption, cb) + syncer.OnEventType(event.StateMember, cb) + syncer.OnEventType(event.StateRoomName, cb) + + // in case some rooms were synced before we added the hooks + for _, room := range store.GetElectionRooms() { + update(room) + } + + var room *election.Room list.SetSelectedFunc(func(i int, _ string, _ string, _ rune) { + if len(rooms) == 0 { + // user hit "syncing..." + return + } app.Stop() - roomID = resp.JoinedRooms[i] + room = rooms[i].Room }) + app.SetRoot(list, true) - err = app.Run() - return + err := app.Run() + + return room, err +} + +type roomWithTitle struct { + *election.Room + title string } -func RoomTUI(client *mautrix.Client, roomID id.RoomID, elections *election.ElectionsMap) (el *election.Election, err error) { +// used by RoomListTUI to sort rooms by title, putting rooms without titles at +// the end and putting all rooms that have elections at the top +func (roomI *roomWithTitle) cmp(roomJ *roomWithTitle) bool { + iHasElections := roomI.HasElections() + jHasElections := roomJ.HasElections() + if iHasElections && !jHasElections { + return true + } + if jHasElections && !iHasElections { + return false + } + _, iHasName := roomI.State[event.StateRoomName] + _, jHasName := roomJ.State[event.StateRoomName] + if iHasName && !jHasName { + return true + } + if jHasName && !iHasName { + return false + } + return roomI.title < roomJ.title +} + + +func RoomTUI(client *mautrix.Client, room *election.Room, electionsMap *election.ElectionsMap, syncer mautrix.ExtensibleSyncer) (el *election.Election, err error) { app := newTallyardApplication() list := tview.NewList(). AddItem("Create Election", "start a new election in this room", 0, nil) list.SetTitle("Select election").SetBorder(true) - var L []*election.Election - var Ltime time.Time = elections.Ltime.Add(-1 * time.Millisecond) + var elections []*election.Election + update := func() { - elections.RLock() - defer elections.RUnlock() - if !Ltime.Before(elections.Ltime) { - return + elections = nil + for _, el := range room.GetElections() { + // don't show elections that are over + if el.Result != nil { + continue + } + i := sort.Search(len(elections), func(i int) bool { + return el.CreateEvt.Timestamp < elections[i].CreateEvt.Timestamp + }) + elections = append(elections[:i], append([]*election.Election{el}, elections[i:]...)...) } - L = elections.L[roomID] - Ltime = elections.Ltime + list.Clear() list.AddItem("Create Election", "start a new election in this room", 0, nil) - for _, el := range L { + for _, el := range elections { title := el.Title if title == "" { title = "" @@ -126,26 +199,21 @@ func RoomTUI(client *mautrix.Client, roomID id.RoomID, elections *election.Elect el.CreateEvt.Sender, el.CreateEvt.ID), 0, nil) } } - go func() { - for app.alive { + + syncer.OnEventType(election.CreateElectionMessage, func(_ mautrix.EventSource, evt *event.Event) { + if app.alive && evt.RoomID == room.ID { app.QueueUpdateDraw(update) - time.Sleep(1 * time.Second) } - }() + }) + + update() + list.SetSelectedFunc(func(i int, _ string, _ string, _ rune) { app.Stop() - elections.RLock() - // don't do anything if the election under the cursor has - // changed - if Ltime.Before(elections.Ltime) && L[i] != elections.L[roomID][i] { - el, err = RoomTUI(client, roomID, elections) - return - } - elections.RUnlock() if i > 0 { // user selected election - el = L[i - 1] + el = elections[i - 1] el.RLock() userJoined := el.LocalVoter != nil el.RUnlock() @@ -156,10 +224,10 @@ func RoomTUI(client *mautrix.Client, roomID id.RoomID, elections *election.Elect // results if it's already started) if !electionConfirmation(el) { // user needs to select a different election - el, err = RoomTUI(client, roomID, elections) + el, err = RoomTUI(client, room, electionsMap, syncer) } else if el.StartID == nil { // user wants to join the election - err = el.JoinElection(client, elections.EventStore) + err = el.JoinElection(client, electionsMap.EventStore) } // otherwise, user wants to wait for results return @@ -172,20 +240,22 @@ func RoomTUI(client *mautrix.Client, roomID id.RoomID, elections *election.Elect } log.Debugf("created election title: %s", title) log.Debugf("created election candidates: %s", candidates) - el, err = elections.CreateElection(client, candidates, title, roomID) + el, err = electionsMap.CreateElection(client, candidates, title, room.ID) if err != nil { return } - err = el.JoinElection(client, elections.EventStore) + err = el.JoinElection(client, electionsMap.EventStore) if err != nil { return } }) + app.SetRoot(list, true) err2 := app.Run() if err == nil { err = err2 } + return } -- 2.38.4