~edwargix/tallyard

e7cfd1c0c51cb46136727e4c751d8fb6f438a5ab — David Florness 2 years ago d6e95cc
Use syncer hooks instead of recurring updates for TUI
5 files changed, 249 insertions(+), 137 deletions(-)

M cmd/tallyard/main.go
M election/map.go
A election/room.go
M matrix/store.go
M ui/tui.go
M cmd/tallyard/main.go => cmd/tallyard/main.go +16 -11
@@ 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,

M election/map.go => election/map.go +9 -34
@@ 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) {

A election/room.go => election/room.go +44 -0
@@ 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
}

M matrix/store.go => matrix/store.go +19 -1
@@ 56,12 56,22 @@ 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
}

M ui/tui.go => ui/tui.go +161 -91
@@ 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 = "<no name>"


@@ 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
}