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
}