~eliasnaur/scatter

a22cda30e943be5aadf0eb55d0a77bce4de1885f — Elias Naur 1 year, 4 months ago
Public release
A  => README.md +29 -0
@@ 1,29 @@
# Scatter

Scatter implements the Signal protocol over federated email.

Very early version to be presented at my Gophercon 2019 talk about [Gio](https://gioui.org).

# Running Scatter

See the [Gio guide](https://gioui.org) for a setup guide for your platform. Then

	$ GO111MODULE=on go run scatter.im/cmd/scatter

for Linux, macOS or Windows. For iOS and Android

	$ go run gioui.org/cmd/gio -target <android|ios> scatter.im/cmd/scatter

## Issues

File bugs and TODOs through the the [issue tracker](https://todo.sr.ht/~eliasnaur/scatter) or send an email
to [~eliasnaur/scatter@todo.sr.ht](mailto:~eliasnaur/scatter@todo.sr.ht). For general discussion, use the
Gio mailing list: [~eliasnaur/gio@lists.sr.ht](mailto:~eliasnaur/gio@lists.sr.ht).

## License

Dual-licensed under MIT or the [UNLICENSE](http://unlicense.org).

## Contributing

[Send patches](https://git-send-email.io/) and questions to [~eliasnaur/gio@lists.sr.ht](mailto:~eliasnaur/gio@lists.sr.ht).

A  => cmd/assets/assets.go +52 -0
@@ 1,52 @@
// SPDX-License-Identifier: Unlicense OR MIT

// Command assets converts data files to Go source code.
package main

import (
	"bytes"
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"
	"strings"
)

func main() {
	if len(os.Args) != 2 {
		fmt.Fprintln(os.Stderr, "please specify a directory to convert")
		os.Exit(1)
	}
	dir := os.Args[1]
	pkgName := filepath.Base(dir)
	var w bytes.Buffer
	w.WriteString("// Code generated by command assets DO NOT EDIT.\n\n")
	fmt.Fprintf(&w, "package %s\n\n", pkgName)
	filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		if filepath.Ext(path) == ".go" {
			return nil
		}
		name := path[len(dir):]
		name = strings.ReplaceAll(name, "/", "_")
		name = strings.ReplaceAll(name, ".", "_")
		name = strings.ReplaceAll(name, "-", "_")
		name = strings.Title(name)
		content, err := ioutil.ReadFile(path)
		if err != nil {
			return err
		}
		fmt.Fprintf(&w, "var %s = %#v\n", name, content)
		return nil
	})
	err := ioutil.WriteFile(filepath.Join(dir, "assets.go"), w.Bytes(), 0644)
	if err != nil {
		fmt.Fprintf(os.Stderr, "%v\n", err)
		os.Exit(1)
	}
}

A  => cmd/scatter/client.go +498 -0
@@ 1,498 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net"
	"net/textproto"
	"sync"
	"time"

	"github.com/emersion/go-imap"
	idle "github.com/emersion/go-imap-idle"
	"github.com/emersion/go-imap/client"
	"github.com/emersion/go-message/mail"
	"github.com/emersion/go-sasl"
	"github.com/emersion/go-smtp"
)

type Client struct {
	cancel  context.CancelFunc
	account *Account

	flushC chan struct{}
	store  *Store

	state *IMAPState

	mu        sync.Mutex
	listeners map[interface{}]chan<- struct{}
	err       error
}

func NewClient(store *Store) (*Client, error) {
	acc, err := store.GetAccount()
	if err != nil {
		return nil, err
	}
	state, err := store.GetIMAPState()
	if err != nil {
		return nil, err
	}
	c := &Client{
		account:   acc,
		state:     state,
		store:     store,
		flushC:    make(chan struct{}, 1),
		listeners: make(map[interface{}]chan<- struct{}),
	}
	// Send messages left over from last run.
	c.flushC <- struct{}{}
	return c, nil
}

func (c *Client) Run() {
	go c.runOutgoing()
	if c.cancel == nil {
		c.restartIncoming()
	}
}

func (c *Client) ContainsSession(addr string) bool {
	return c.store.ContainsSession(addr)
}

func (s *Client) Thread(addr string) *Thread {
	return s.store.Thread(addr)
}

func (c *Client) Account() *Account {
	acc := *c.account
	return &acc
}

func (c *Client) SetAccount(acc *Account) error {
	copy := *acc
	c.account = &copy
	if err := c.store.SetAccount(acc); err != nil {
		return err
	}
	c.restartIncoming()
	return nil
}

func (c *Client) restartIncoming() {
	if c.cancel != nil {
		c.cancel()
	}
	ctx, cancel := context.WithCancel(context.Background())
	c.cancel = cancel
	go c.runIncoming(ctx)
}

func (c *Client) Threads() ([]*Thread, error) {
	return c.store.ThreadsByDate()
}

func (c *Client) QueryThreads(q string) ([]*Thread, error) {
	return c.store.ThreadsByPrefix(q)
}

func (c *Client) Messages(sender string) ([]*Message, error) {
	return c.store.Messages(sender)
}

func (c *Client) List() error {
	threads, err := c.Threads()
	if err != nil {
		return err
	}
	log.Println("Threads:")
	for _, t := range threads {
		log.Printf("%s (%d)", t.ID, t.Messages)
		msgs, err := c.Messages(t.ID)
		if err != nil {
			return nil
		}
		for _, m := range msgs {
			log.Printf("%s: %s", m.Time, m.Message)
		}
	}
	return nil
}

func (c *Client) Close() error {
	return c.store.Close()
}

func (c *Client) runIncoming(ctx context.Context) {
	var retry time.Duration
	acc := c.Account()
	for {
		log.Printf("connecting to %s", acc.IMAPHost)
		cl, err := client.DialTLS(acc.IMAPHost, nil)
		if err != nil {
			log.Printf("imap connection failed: %v", err)
			if err, ok := err.(*net.OpError); ok && err.Temporary() {
				backoff(&retry)
				continue
			} else {
				c.setError(err)
				break
			}
		}
		retry = 0
		defer cl.Logout()
		log.Printf("connected to %s", acc.IMAPHost)
		if err := cl.Login(acc.User, acc.Password); err != nil {
			log.Printf("imap login failed: %v", err)
			c.setError(err)
			break
		}
		mb, err := cl.Select(c.state.Mailbox, false)
		if err != nil {
			log.Printf("selecting mailbox %s failed: %v", c.state.Mailbox, err)
			c.setError(err)
			break
		}
		c.updateValidity(mb.UidValidity)
		log.Printf("signed in to %s", acc.IMAPHost)
		stop, err := c.listen(ctx, cl)
		if err != nil {
			log.Printf("imap connection lost: %v", err)
		}
		if stop {
			break
		}
	}
}

func (c *Client) updateValidity(uidValidity uint32) {
	if uidValidity != c.state.UIDValidity {
		c.state.LastUID = 1
	}
	c.state.UIDValidity = uidValidity
	c.store.SetIMAPState(c.state)
}

func (c *Client) listen(ctx context.Context, cl *client.Client) (bool, error) {
	notify := make(chan *imap.MailboxStatus, 1)
	updates := make(chan client.Update)
	cl.Updates = updates
	go func() {
		for upd := range updates {
			switch u := upd.(type) {
			case *client.MailboxUpdate:
				select {
				case notify <- u.Mailbox:
				default:
				}
			}
		}
	}()
	if err := c.fetchNewMessages(cl); err != nil {
		return false, err
	}
	done, stop := c.idle(cl)

	for {
		select {
		case err := <-done:
			if err != nil {
				return false, err
			}
		case mb := <-notify:
			stop <- struct{}{}
			if err := <-done; err != nil {
				return false, err
			}
			c.updateValidity(mb.UidValidity)
			if err := c.fetchNewMessages(cl); err != nil {
				return false, err
			}
			if mb.UidNext > c.state.LastUID {
				c.state.LastUID = mb.UidNext
				c.store.SetIMAPState(c.state)
			}
		case <-ctx.Done():
			return true, ctx.Err()
		}
		done, stop = c.idle(cl)
	}
}

func (c *Client) idle(cl *client.Client) (<-chan error, chan<- struct{}) {
	idleClient := idle.NewClient(cl)
	done := make(chan error, 1)
	stop := make(chan struct{})
	go func() {
		done <- idleClient.IdleWithFallback(stop, 0)
	}()
	return done, stop
}

func (c *Client) fetchNewMessages(cl *client.Client) error {
	allUids := new(imap.SeqSet)
	allUids.AddRange(c.state.LastUID, ^uint32(0))
	ids, err := cl.UidSearch(&imap.SearchCriteria{
		Uid: allUids,
		Header: textproto.MIMEHeader{
			"X-Scatter-Msg": []string{""},
		},
	})
	if err != nil {
		return err
	}

	if len(ids) == 0 {
		return nil
	}
	seqset := new(imap.SeqSet)
	seqset.AddNum(ids...)

	var section imap.BodySectionName
	messages := make(chan *imap.Message, len(ids))
	done := make(chan error, 1)
	go func() {
		done <- cl.UidFetch(seqset, []imap.FetchItem{section.FetchItem(), imap.FetchUid, imap.FetchEnvelope}, messages)
	}()

	delSet := new(imap.SeqSet)
loop:
	for msg := range messages {
		if msg.Envelope == nil {
			continue
		}
		if msg.Uid >= c.state.LastUID {
			c.state.LastUID = msg.Uid + 1
			c.store.SetIMAPState(c.state)
		}
		sender := msg.Envelope.From[0]
		r := msg.GetBody(&section)
		if r == nil {
			continue
		}

		// Create a new mail reader
		mr, err := mail.CreateReader(r)
		if err != nil {
			log.Printf("failed to read message body: %v", err)
			continue
		}

		for {
			p, err := mr.NextPart()
			if err == io.EOF {
				break
			} else if err != nil {
				log.Printf("failed to read message part: %v", err)
				continue loop
			}
			switch h := p.Header.(type) {
			case *mail.AttachmentHeader:
				filename, err := h.Filename()
				if err != nil {
					log.Printf("failed to read attachment: %v", err)
					continue loop
				}
				if filename != "message" {
					continue loop
				}
				addr := fmt.Sprintf("%s@%s", sender.MailboxName, sender.HostName)
				if err := c.receive(addr, p.Body); err != nil {
					log.Printf("failed to parse message: %v", err)
					continue loop
				}
			}
		}
		delSet.AddNum(msg.Uid)
	}

	if err := <-done; err != nil {
		return err
	}
	if delSet.Empty() {
		return nil
	}
	item := imap.FormatFlagsOp(imap.AddFlags, true)
	flags := []interface{}{imap.DeletedFlag}
	if err = cl.UidStore(delSet, item, flags, nil); err != nil {
		return err
	}
	if err := cl.Expunge(nil); err != nil {
		return err
	}
	return nil
}

func (c *Client) register(key interface{}) <-chan struct{} {
	c.mu.Lock()
	defer c.mu.Unlock()
	if _, exists := c.listeners[key]; exists {
		panic("only one registration per key is allowed")
	}
	ch := make(chan struct{}, 1)
	c.listeners[key] = ch
	return ch
}

func (c *Client) unregister(key interface{}) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if _, exists := c.listeners[key]; !exists {
		panic("no registration found")
	}
	delete(c.listeners, key)
}

func (c *Client) receive(addr string, msg io.Reader) error {
	var wmsg WireMessage
	if err := json.NewDecoder(msg).Decode(&wmsg); err != nil {
		return err
	}
	if err := c.store.Receive(addr, &wmsg); err != nil {
		return err
	}
	c.notify()
	return nil
}

func (c *Client) Err() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	err := c.err
	c.err = nil
	return err
}

func (c *Client) setError(err error) {
	c.mu.Lock()
	c.err = err
	c.mu.Unlock()
	c.notify()
}

func (c *Client) notify() {
	c.mu.Lock()
	defer c.mu.Unlock()
	for _, l := range c.listeners {
		select {
		case l <- struct{}{}:
		default:
		}
	}
}

func (c *Client) Send(addr, body string) error {
	err := c.store.Send(&Message{
		Thread:  addr,
		Own:     true,
		Time:    time.Now(),
		Message: body,
	})
	if err != nil {
		return err
	}
	c.notify()
	c.flush()
	return nil
}

func (c *Client) MarkRead(addr string) error {
	marked, err := c.store.MarkRead(addr)
	if err != nil {
		return err
	}
	if marked {
		c.notify()
	}
	return nil
}

func (c *Client) flush() {
	select {
	case c.flushC <- struct{}{}:
	default:
	}
}

func (c *Client) runOutgoing() {
	var retry time.Duration
	for range c.flushC {
		for {
			msg, wmsg, err := c.store.NextOutgoing()
			if err != nil {
				log.Printf("failed to fetch outgoing message: %v", err)
				break
			}
			if msg == nil {
				break
			}
			if err := c.send(msg, wmsg); err != nil {
				log.Printf("failed to send message: %v", err)
				backoff(&retry)
				continue
			}
			retry = 0
			if err := c.store.DeleteOutgoing(msg.ID); err != nil {
				log.Printf("failed to delete outgoing message: %v", err)
				break
			}
			c.notify()
		}
	}
}

func backoff(sleep *time.Duration) {
	if *sleep < time.Second {
		*sleep = time.Second
	}
	time.Sleep(*sleep)
	*sleep *= 2
	if max := time.Minute; *sleep > max {
		*sleep = max
	}
}

func (c *Client) send(msg *Message, wireMsg []byte) error {
	const marker = "ATTACHMENTMARKER"
	const maxLineLen = 500

	auth := sasl.NewPlainClient("", c.account.User, c.account.Password)
	var buf bytes.Buffer
	buf.WriteString("From: " + *user + "\r\n" +
		"From: " + *user + "\r\n" +
		"Subject: New Scatter message\r\n" +
		"MIME-Version: 1.0\r\n" +
		"Content-Type: multipart/mixed; boundary=" + marker + "\r\n" +
		"X-Scatter-Msg: \r\n" +
		"--" + marker + "\r\n" +
		"Content-Type: text/plain; charset=\"UTF-8\"\r\n" +
		"Content-Transfer-Encoding: 8bit\r\n\r\n" +
		"Hello,\r\n" +
		"\r\n" +
		"This is an encrypted message sent with Scatter. Download the app from https://scatter.im to open it.\r\n" +
		"--" + marker + "\r\n" +
		"Content-Type: application/octet-stream; name=\"message\"\r\n" +
		"Content-Transfer-Encoding: base64\r\n" +
		"Content-Disposition: attachment; filename=\"message\"\r\n\r\n")
	encMsg := base64.StdEncoding.EncodeToString(wireMsg)
	for len(encMsg) > 0 {
		n := maxLineLen
		if n > len(encMsg) {
			n = len(encMsg)
		}
		buf.WriteString(encMsg[:n])
		buf.WriteString("\r\n")
		encMsg = encMsg[n:]
	}
	buf.WriteString("--" + marker + "--\r\n")
	return smtp.SendMail(c.account.SMTPHost, auth, c.account.User, []string{msg.Thread}, &buf)
}

A  => cmd/scatter/main.go +111 -0
@@ 1,111 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main

import (
	"flag"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"sync"
	"time"

	"gioui.org/ui/app"
)

var (
	imapHost = flag.String("imap", "", "specify the IMAP host and port")
	smtpHost = flag.String("smtp", "", "specify the SMTP host and port")
	user     = flag.String("user", "", "specify the username (e.g. user@example.com)")
	pass     = flag.String("pass", "", "specify the password")

	dataDir string
)

var (
	clientOnce   sync.Once
	singleClient *Client
)

func init() {
	log.SetPrefix("scatter: ")
	flag.Usage = func() {
		fmt.Fprintf(os.Stderr, "Scatter is a federated implementation of the Signal protocol on email.\n\n")
		fmt.Fprintf(os.Stderr, "Usage:\n\n\tscatter [flags] <pkg>\n\n")
		flag.PrintDefaults()
		os.Exit(2)
	}
	conf, err := os.UserConfigDir()
	if err == nil {
		conf = filepath.Join(conf, "scatter")
	}
	flag.StringVar(&dataDir, "store", conf, "directory for storing configuration and messages")
	flag.Parse()
}

func getClient() *Client {
	clientOnce.Do(func() {
		cl, err := initClient()
		if err != nil {
			errorf("scatter: %v", err)
		}
		singleClient = cl
	})
	return singleClient
}

func initClient() (*Client, error) {
	dir := dataDir
	if dir == "" {
		var err error
		dir, err = app.DataDir()
		if err != nil {
			errorf("scatter: %v", err)
		}
		dir = filepath.Join(dir, "scatter")
	}
	if err := os.MkdirAll(dir, 0700); err != nil {
		return nil, err
	}
	store, err := OpenStore(filepath.Join(dir, "store.db"))
	if err != nil {
		return nil, err
	}
	acc, err := store.GetAccount()
	if err != nil {
		store.Close()
		return nil, err
	}
	if *imapHost != "" {
		acc.IMAPHost = *imapHost
	}
	if *smtpHost != "" {
		acc.SMTPHost = *smtpHost
	}
	if *user != "" {
		acc.User = *user
	}
	if *pass != "" {
		acc.Password = *pass
	}
	store.SetAccount(acc)
	cl, err := NewClient(store)
	if err != nil {
		store.Close()
		return nil, err
	}
	cl.Run()
	singleClient = cl
	return cl, nil
}

func main() {
	uiMain()
}

func errorf(format string, args ...interface{}) {
	fmt.Fprintf(os.Stderr, format+"\n", args...)
	time.Sleep(5 * time.Second)
	os.Exit(1)
}

A  => cmd/scatter/signal.go +399 -0
@@ 1,399 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main

import (
	"encoding/json"
	"errors"
	"fmt"

	"github.com/eliasnaur/libsignal-protocol-go/ecc"
	"github.com/eliasnaur/libsignal-protocol-go/keys/identity"
	"github.com/eliasnaur/libsignal-protocol-go/keys/prekey"
	"github.com/eliasnaur/libsignal-protocol-go/protocol"
	"github.com/eliasnaur/libsignal-protocol-go/serialize"
	"github.com/eliasnaur/libsignal-protocol-go/session"
	"github.com/eliasnaur/libsignal-protocol-go/state/record"
	"github.com/eliasnaur/libsignal-protocol-go/util/keyhelper"
	bolt "go.etcd.io/bbolt"
)

type ID struct {
	RegID      uint32
	DeviceID   uint32
	PublicKey  [32]byte
	PrivateKey [32]byte
}

type SignalStore struct {
	Bucket *bolt.Bucket
	Err    error
}

func (s *SignalStore) GetIdentityKeyPair() *identity.KeyPair {
	data := s.Bucket.Get([]byte("id"))
	var id ID
	if err := json.Unmarshal(data, &id); err != nil {
		s.Err = err
		return nil
	}
	pub := identity.NewKeyFromBytes(id.PublicKey, 0)
	priv := ecc.NewDjbECPrivateKey(id.PrivateKey)
	return identity.NewKeyPair(&pub, priv)
}

func (s *SignalStore) GetLocalRegistrationId() uint32 {
	data := s.Bucket.Get([]byte("id"))
	var id ID
	s.Err = json.Unmarshal(data, &id)
	return id.RegID
}

func (s *SignalStore) getID() *ID {
	data := s.Bucket.Get([]byte("id"))
	var id ID
	s.Err = json.Unmarshal(data, &id)
	return &id
}

func (s *SignalStore) SaveIdentity(addr *protocol.SignalAddress, key *identity.Key) {
	ids, err := s.Bucket.CreateBucketIfNotExists([]byte("identities"))
	if err != nil {
		s.Err = err
		return
	}
	pub := key.PublicKey().PublicKey()
	s.Err = ids.Put([]byte(addr.String()), pub[:])
}

func (s *SignalStore) IsTrustedIdentity(addr *protocol.SignalAddress, id *identity.Key) bool {
	ids := s.Bucket.Bucket([]byte("identities"))
	if ids == nil {
		return true
	}
	trustedData := ids.Get([]byte(addr.String()))
	if trustedData == nil {
		return true
	}
	if len(trustedData) != 32 {
		panic("invalid key")
	}
	var keyBytes [32]byte
	copy(keyBytes[:], trustedData)
	trusted := identity.NewKeyFromBytes(keyBytes, 0)
	return trusted.Fingerprint() == id.Fingerprint()
}

func (s *SignalStore) LoadSignedPreKey(keyID uint32) *record.SignedPreKey {
	skeys := s.Bucket.Bucket([]byte("signedPreKeys"))
	if skeys == nil {
		return nil
	}
	data := skeys.Get(itob(uint64(keyID)))
	if data == nil {
		return nil
	}
	pk, err := unmarshalSignedPreKey(data)
	s.Err = err
	return pk
}

func unmarshalSignedPreKey(data []byte) (*record.SignedPreKey, error) {
	serializer := serialize.NewJSONSerializer()
	pkStruct, err := serializer.SignedPreKeyRecord.Deserialize(data)
	if err != nil {
		return nil, err
	}
	// BUG: The JSON signed prekey serializer is not idempotent: it adds the key type (5)
	// and cuts off the last key byte every roundtrip. Chop the type off before continuing.
	pkStruct.PublicKey = pkStruct.PublicKey[1:]
	return record.NewSignedPreKeyFromStruct(pkStruct, serializer.SignedPreKeyRecord)
}

func (s *SignalStore) StoreSignedPreKey(keyID uint32, record *record.SignedPreKey) {
	skeys, err := s.Bucket.CreateBucketIfNotExists([]byte("signedPreKeys"))
	if err != nil {
		s.Err = err
		return
	}
	data := record.Serialize()
	s.Err = skeys.Put(itob(uint64(keyID)), data)
}

func (s *SignalStore) LoadSignedPreKeys() []*record.SignedPreKey {
	skeys := s.Bucket.Bucket([]byte("signedPreKeys"))
	if skeys == nil {
		return nil
	}
	var keys []*record.SignedPreKey
	c := skeys.Cursor()
	for k, v := c.First(); k != nil; k, v = c.Next() {
		pk, err := unmarshalSignedPreKey(v)
		if err != nil {
			s.Err = err
			return keys
		}
		keys = append(keys, pk)
	}
	return keys
}

func (s *SignalStore) ContainsSignedPreKey(keyID uint32) bool {
	return s.LoadSignedPreKey(keyID) != nil
}

func (s *SignalStore) RemoveSignedPreKey(keyID uint32) {
	skeys := s.Bucket.Bucket([]byte("signedPreKeys"))
	if skeys == nil {
		return
	}
	s.Err = skeys.Delete(itob(uint64(keyID)))
}

func (s *SignalStore) StorePreKey(keyID uint32, keyRec *record.PreKey) {
	keys, err := s.Bucket.CreateBucketIfNotExists([]byte("preKeys"))
	if err != nil {
		s.Err = err
		return
	}
	s.Err = keys.Put(itob(uint64(keyID)), keyRec.Serialize())
}

func (s *SignalStore) ContainsPreKey(keyID uint32) bool {
	return s.LoadPreKey(keyID) != nil
}

func (s *SignalStore) RemovePreKey(keyID uint32) {
	keys := s.Bucket.Bucket([]byte("preKeys"))
	if keys == nil {
		return
	}
	s.Err = keys.Delete(itob(uint64(keyID)))
}

func (s *SignalStore) LoadPreKey(keyID uint32) *record.PreKey {
	keys := s.Bucket.Bucket([]byte("preKeys"))
	if keys == nil {
		return nil
	}
	data := keys.Get(itob(uint64(keyID)))
	if data == nil {
		return nil
	}
	serializer := serialize.NewJSONSerializer()
	key, err := record.NewPreKeyFromBytes(data, serializer.PreKeyRecord)
	s.Err = err
	return key
}

func (s *SignalStore) LoadSession(addr *protocol.SignalAddress) *record.Session {
	sess, err := s.loadSession(addr)
	if err != nil {
		s.Err = err
		return nil
	}
	if sess == nil {
		serializer := serialize.NewJSONSerializer()
		sess = record.NewSession(serializer.Session, serializer.State)
	}
	return sess
}

func (s *SignalStore) loadSession(addr *protocol.SignalAddress) (*record.Session, error) {
	sessions := s.Bucket.Bucket([]byte("sessions"))
	if sessions == nil {
		return nil, nil
	}
	serializer := serialize.NewJSONSerializer()
	data := sessions.Get([]byte(addr.String()))
	if data == nil {
		return nil, nil
	}
	sess, err := record.NewSessionFromBytes(data, serializer.Session, serializer.State)
	s.Err = err
	return sess, nil
}

func (s *SignalStore) GetSubDeviceSessions(name string) []uint32 {
	panic("unimplemented")
}

func (s *SignalStore) StoreSession(addr *protocol.SignalAddress, record *record.Session) {
	sessions, err := s.Bucket.CreateBucketIfNotExists([]byte("sessions"))
	if err != nil {
		s.Err = err
		return
	}
	s.Err = sessions.Put([]byte(addr.String()), record.Serialize())
}

func (s *SignalStore) ContainsSession(addr *protocol.SignalAddress) bool {
	sess, err := s.loadSession(addr)
	s.Err = err
	return sess != nil
}

func (s *SignalStore) DeleteSession(addr *protocol.SignalAddress) {
	sessions := s.Bucket.Bucket([]byte("sessions"))
	if sessions == nil {
		return
	}
	s.Err = sessions.Delete([]byte(addr.String()))
}

func (s *SignalStore) DeleteAllSessions() {
	s.Err = s.Bucket.DeleteBucket([]byte("sessions"))
}

func (s *SignalStore) ProcessBundle(addr string, bundle *prekey.Bundle) error {
	saddr := protocol.NewSignalAddress(addr, bundle.DeviceID())
	return s.newSessionBuilder(saddr).ProcessBundle(bundle)
}

func (s *SignalStore) newSessionBuilder(addr *protocol.SignalAddress) *session.Builder {
	serializer := serialize.NewJSONSerializer()
	return session.NewBuilder(
		s,
		s,
		s,
		s,
		addr,
		serializer,
	)
}

func (s *SignalStore) NewPreKey() (*record.PreKey, error) {
	keys, err := s.Bucket.CreateBucketIfNotExists([]byte("preKeys"))
	if err != nil {
		return nil, err
	}
	keyID, err := keys.NextSequence()
	if err != nil {
		return nil, err
	}
	keyID32 := uint32(keyID)
	if uint64(keyID32) != keyID {
		return nil, errors.New("prekey id overflow")
	}
	keyPair, err := ecc.GenerateKeyPair()
	if err != nil {
		return nil, err
	}
	serializer := serialize.NewJSONSerializer()
	preKey := record.NewPreKey(keyID32, keyPair, serializer.PreKeyRecord)
	s.StorePreKey(keyID32, preKey)
	return preKey, s.Err
}

func (s *SignalStore) Init() error {
	idKeyPair, err := keyhelper.GenerateIdentityKeyPair()
	if err != nil {
		return err
	}
	priv := idKeyPair.PrivateKey().Serialize()
	id := &ID{
		RegID:      keyhelper.GenerateRegistrationID(),
		DeviceID:   keyhelper.GenerateRegistrationID(),
		PrivateKey: priv,
		PublicKey:  idKeyPair.PublicKey().PublicKey().PublicKey(),
	}
	data, err := json.Marshal(id)
	if err != nil {
		return err
	}
	if err := s.Bucket.Put([]byte("id"), data); err != nil {
		return err
	}
	serializer := serialize.NewJSONSerializer()
	signedPreKey, err := keyhelper.GenerateSignedPreKey(idKeyPair, 0, serializer.SignedPreKeyRecord)
	if err != nil {
		return err
	}
	s.StoreSignedPreKey(
		signedPreKey.ID(),
		record.NewSignedPreKey(
			signedPreKey.ID(),
			signedPreKey.Timestamp(),
			signedPreKey.KeyPair(),
			signedPreKey.Signature(),
			serializer.SignedPreKeyRecord,
		),
	)
	return s.Err
}

func (s *SignalStore) Decrypt(addr *protocol.SignalAddress, stype uint32, data []byte) ([]byte, error) {
	serializer := serialize.NewJSONSerializer()
	var message *protocol.SignalMessage
	var builder *session.Builder
	switch stype {
	case protocol.PREKEY_TYPE:
		msg, err := protocol.NewPreKeySignalMessageFromBytes(data, serializer.PreKeySignalMessage, serializer.SignalMessage)
		if err != nil {
			return nil, err
		}
		builder = s.newSessionBuilder(addr)
		sess := s.LoadSession(addr)
		if _, err := builder.Process(sess, msg); err != nil {
			return nil, err
		}
		s.StoreSession(addr, sess)
		message = msg.WhisperMessage()
	case protocol.WHISPER_TYPE:
		msg, err := protocol.NewSignalMessageFromBytes(data, serializer.SignalMessage)
		if err != nil {
			return nil, err
		}
		message = msg
		builder = s.newSessionBuilder(addr)
	default:
		return nil, fmt.Errorf("decrypt: unknown signal message type: %v", stype)
	}
	cipher := session.NewCipher(builder, addr)
	return cipher.Decrypt(message)
}

func (s *SignalStore) Encrypt(addr *protocol.SignalAddress, msg []byte) (uint32, []byte, error) {
	cipher, err := s.newCipher(addr)
	if err != nil {
		return 0, nil, err
	}
	data, err := cipher.Encrypt(msg)
	if err != nil {
		return 0, nil, err
	}
	return data.Type(), data.Serialize(), nil
}

func (s *SignalStore) newCipher(addr *protocol.SignalAddress) (*session.Cipher, error) {
	serializer := serialize.NewJSONSerializer()
	cipher := session.NewCipherFromSession(addr, s, s, s, serializer.PreKeySignalMessage, serializer.SignalMessage)
	return cipher, nil
}

func (s *SignalStore) NewBundle() (*prekey.Bundle, error) {
	pk, err := s.NewPreKey()
	if err != nil {
		return nil, err
	}
	spk, err := s.LoadSignedPreKey(0), s.Err
	if err != nil {
		return nil, err
	}
	idkp, err := s.GetIdentityKeyPair(), s.Err
	if err != nil {
		return nil, err
	}
	id := s.getID()
	b := prekey.NewBundle(
		id.RegID,
		id.DeviceID,
		pk.ID(),
		spk.ID(),
		pk.KeyPair().PublicKey(),
		spk.KeyPair().PublicKey(),
		spk.Signature(),
		idkp.PublicKey(),
	)
	return b, nil
}

A  => cmd/scatter/store.go +514 -0
@@ 1,514 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main

import (
	"bytes"
	"encoding/binary"
	"encoding/json"
	"fmt"
	"time"

	bolt "go.etcd.io/bbolt"

	"github.com/eliasnaur/libsignal-protocol-go/ecc"
	"github.com/eliasnaur/libsignal-protocol-go/keys/identity"
	"github.com/eliasnaur/libsignal-protocol-go/keys/prekey"
	"github.com/eliasnaur/libsignal-protocol-go/protocol"
	"github.com/eliasnaur/libsignal-protocol-go/util/optional"
)

type Store struct {
	db *bolt.DB
}

type Account struct {
	User     string
	Password string
	IMAPHost string
	SMTPHost string
}

type IMAPState struct {
	Mailbox     string
	LastUID     uint32
	UIDValidity uint32
}

type Message struct {
	ID      uint64
	Thread  string
	Own     bool
	Sent    bool
	Time    time.Time
	Message string
}

type WireMessage struct {
	Type       MessageType
	SignalType uint32
	Data       []byte
}

type MessageType string

const (
	MessageBundle MessageType = "bundle"
	MessageText   MessageType = "message"
)

// Bundle is a serializable prekey.Bundle.
type Bundle struct {
	RegID           uint32
	DeviceID        uint32
	IDKey           [32]byte
	PreKeyID        uint32
	PreKey          [32]byte
	SignedPreKeyID  uint32
	SignedPreKey    [32]byte
	SignedPreKeySig [64]byte
}

type Thread struct {
	// ID (email address) of the peer.
	ID string
	// DeviceID of the peer.
	DeviceID uint32
	// Message count.
	Messages int
	// Unread count.
	Unread int
	// Snippet is the message to be shown in
	// a thread overview.
	Snippet string
	// Updated tracks the last message time.
	Updated time.Time
	// SortKey is the sorting key for this thread.
	SortKey           string
	PendingInvitation bool
}

func OpenStore(path string) (*Store, error) {
	db, err := bolt.Open(path, 0666, nil)
	if err != nil {
		return nil, err
	}
	s := &Store{db: db}
	err = db.Update(func(tx *bolt.Tx) error {
		buckets := []string{"threads", "messages", "outgoing", "account", "sortedThreads"}
		for _, b := range buckets {
			if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil {
				return err
			}
		}
		return s.initSignalState(tx)
	})
	if err != nil {
		db.Close()
		return nil, err
	}
	return s, nil
}

func toBundle(b *prekey.Bundle) *Bundle {
	return &Bundle{
		RegID:           b.RegistrationID(),
		DeviceID:        b.DeviceID(),
		IDKey:           b.IdentityKey().PublicKey().PublicKey(),
		PreKeyID:        b.PreKeyID().Value,
		PreKey:          b.PreKey().PublicKey(),
		SignedPreKeyID:  b.SignedPreKeyID(),
		SignedPreKey:    b.SignedPreKey().PublicKey(),
		SignedPreKeySig: b.SignedPreKeySignature(),
	}
}

func fromBundle(b *Bundle) *prekey.Bundle {
	idkey := identity.NewKeyFromBytes(b.IDKey, 0)
	return prekey.NewBundle(
		b.RegID,
		b.DeviceID,
		optional.NewOptionalUint32(b.PreKeyID),
		b.SignedPreKeyID,
		ecc.NewDjbECPublicKey(b.PreKey),
		ecc.NewDjbECPublicKey(b.SignedPreKey),
		b.SignedPreKeySig,
		&idkey,
	)
}

func (s *Store) initSignalState(tx *bolt.Tx) error {
	signal := tx.Bucket([]byte("signal"))
	if signal != nil {
		return nil
	}
	signal, err := tx.CreateBucket([]byte("signal"))
	if err != nil {
		return err
	}
	ss := s.NewSignalStore(tx)
	return ss.Init()
}

func (s *Store) NewSignalStore(tx *bolt.Tx) *SignalStore {
	return &SignalStore{Bucket: tx.Bucket([]byte("signal"))}
}

func (s *Store) ContainsSession(addr string) bool {
	avail := false
	s.db.View(func(tx *bolt.Tx) error {
		threads := tx.Bucket([]byte("threads"))
		header := s.getHeader(threads.Bucket([]byte(addr)))
		ss := s.NewSignalStore(tx)
		avail = ss.ContainsSession(protocol.NewSignalAddress(header.ID, header.DeviceID))
		return nil
	})
	return avail
}

func (s *Store) GetAccount() (*Account, error) {
	var acc Account
	return &acc, s.db.View(func(tx *bolt.Tx) error {
		account := tx.Bucket([]byte("account"))
		data := account.Get([]byte("credentials"))
		if data == nil {
			return nil
		}
		return json.Unmarshal(data, &acc)
	})
}

func (s *Store) SetAccount(acc *Account) error {
	return s.db.Update(func(tx *bolt.Tx) error {
		account := tx.Bucket([]byte("account"))
		data, err := json.Marshal(acc)
		if err != nil {
			return err
		}
		return account.Put([]byte("credentials"), data)
	})
}

func (s *Store) GetIMAPState() (*IMAPState, error) {
	state := IMAPState{
		Mailbox: "INBOX",
		// IMAP uids start at 1.
		LastUID: 1,
	}
	return &state, s.db.View(func(tx *bolt.Tx) error {
		account := tx.Bucket([]byte("account"))
		data := account.Get([]byte("imapState"))
		if data == nil {
			return nil
		}
		return json.Unmarshal(data, &state)
	})
}

func (s *Store) SetIMAPState(state *IMAPState) error {
	return s.db.Update(func(tx *bolt.Tx) error {
		account := tx.Bucket([]byte("account"))
		data, err := json.Marshal(state)
		if err != nil {
			return err
		}
		return account.Put([]byte("imapState"), data)
	})
}

func (s *Store) Receive(addr string, wmsg *WireMessage) error {
	return s.db.Update(func(tx *bolt.Tx) error {
		ss := s.NewSignalStore(tx)
		switch t := wmsg.Type; t {
		case MessageBundle:
			var b Bundle
			if err := json.Unmarshal(wmsg.Data, &b); err != nil {
				return err
			}
			sb := fromBundle(&b)
			if err := ss.ProcessBundle(addr, sb); err != nil {
				return err
			}
			m := &Message{
				Thread:  addr,
				Time:    time.Now(),
				Message: "Invitation received",
			}
			_, err := s.addMessage(tx, m, sb)
			return err
		case MessageText:
			ss := s.NewSignalStore(tx)
			thread := tx.Bucket([]byte("threads")).Bucket([]byte(addr))
			header := s.getHeader(thread)
			saddr := protocol.NewSignalAddress(addr, header.DeviceID)
			msg, err := ss.Decrypt(saddr, wmsg.SignalType, wmsg.Data)
			if err != nil {
				return err
			}
			m := &Message{
				Thread:  addr,
				Time:    time.Now(),
				Message: string(msg),
			}
			_, err = s.addMessage(tx, m, nil)
			return err
		default:
			return fmt.Errorf("unknown message type: %s", t)
		}
	})
}

func (s *Store) Send(m *Message) error {
	return s.db.Update(func(tx *bolt.Tx) error {
		thread := tx.Bucket([]byte("threads")).Bucket([]byte(m.Thread))
		header := s.getHeader(thread)
		m.Own = true
		msgID, err := s.addMessage(tx, m, nil)
		if err != nil {
			return err
		}
		ss := s.NewSignalStore(tx)
		var wm *WireMessage
		addr := protocol.NewSignalAddress(m.Thread, header.DeviceID)
		if ss.ContainsSession(addr) {
			sigType, data, err := ss.Encrypt(addr, []byte(m.Message))
			if err != nil {
				return err
			}
			wm = &WireMessage{Type: MessageText, SignalType: sigType, Data: data}
		} else {
			bundle, err := ss.NewBundle()
			if err != nil {
				return err
			}
			data, err := json.Marshal(toBundle(bundle))
			if err != nil {
				return err
			}
			wm = &WireMessage{Type: MessageBundle, Data: data}
		}
		data, err := json.Marshal(wm)
		if err != nil {
			return err
		}
		outgoing := tx.Bucket([]byte("outgoing"))
		return outgoing.Put(itob(msgID), data)
	})
}

func (s *Store) addMessage(tx *bolt.Tx, m *Message, bundle *prekey.Bundle) (uint64, error) {
	threads := tx.Bucket([]byte("threads"))
	thread, err := threads.CreateBucketIfNotExists([]byte(m.Thread))
	if err != nil {
		return 0, err
	}
	header := s.getHeader(thread)
	header.ID = m.Thread
	header.Messages++
	header.Snippet = m.Message
	sortKey := header.SortKey
	header.SortKey = fmt.Sprintf("%d|%d", m.Time.Unix(), m.ID)
	header.Updated = m.Time
	header.Unread++
	if bundle != nil {
		header.DeviceID = bundle.DeviceID()
		header.PendingInvitation = true
	} else {
		header.PendingInvitation = false
	}
	if err := s.putHeader(thread, header); err != nil {
		return 0, err
	}
	if err := s.indexThread(tx, header.ID, sortKey, header.SortKey); err != nil {
		return 0, err
	}
	messages := tx.Bucket([]byte("messages"))
	msgID, err := messages.NextSequence()
	if err != nil {
		return 0, err
	}
	m.ID = msgID
	data, err := json.Marshal(m)
	if err != nil {
		return 0, err
	}
	if err := messages.Put(itob(msgID), data); err != nil {
		return 0, err
	}
	threadMsgs, err := thread.CreateBucketIfNotExists([]byte("messages"))
	if err != nil {
		return 0, err
	}
	return msgID, threadMsgs.Put(itob(msgID), nil)
}

func (s *Store) indexThread(tx *bolt.Tx, tid, oldKey, newKey string) error {
	sortedThreads := tx.Bucket([]byte("sortedThreads"))
	if oldKey != "" {
		if err := sortedThreads.Delete([]byte(oldKey)); err != nil {
			return fmt.Errorf("indexThread: delete old key failed: %v", err)
		}
	}
	if err := sortedThreads.Put([]byte(newKey), []byte(tid)); err != nil {
		return fmt.Errorf("indexThread: add new key failed: %v", err)
	}
	return nil
}

func (s *Store) MarkRead(sender string) (bool, error) {
	marked := false
	return marked, s.db.Update(func(tx *bolt.Tx) error {
		thread := tx.Bucket([]byte("threads")).Bucket([]byte(sender))
		if thread == nil {
			return nil
		}
		header := s.getHeader(thread)
		if header.Unread == 0 {
			return nil
		}
		marked = true
		header.Unread = 0
		return s.putHeader(thread, header)
	})
}

func (s *Store) Thread(addr string) *Thread {
	var thread *Thread
	s.db.View(func(tx *bolt.Tx) error {
		b := tx.Bucket([]byte("threads")).Bucket([]byte(addr))
		thread = s.getHeader(b)
		if thread.ID == "" {
			thread.ID = addr
		}
		return nil
	})
	return thread
}

func (s *Store) Messages(sender string) ([]*Message, error) {
	var messages []*Message
	return messages, s.db.View(func(tx *bolt.Tx) error {
		b := tx.Bucket([]byte("threads")).Bucket([]byte(sender))
		if b == nil {
			return nil
		}
		b = b.Bucket([]byte("messages"))
		if b == nil {
			return nil
		}
		c := b.Cursor()
		msgBucket := tx.Bucket([]byte("messages"))
		for k, _ := c.First(); k != nil; k, _ = c.Next() {
			data := msgBucket.Get(k)
			var m Message
			if err := json.Unmarshal(data, &m); err != nil {
				return err
			}
			messages = append(messages, &m)
		}
		return nil
	})
}

func (s *Store) NextOutgoing() (*Message, []byte, error) {
	var msg *Message
	var wireMsg []byte
	return msg, wireMsg, s.db.View(func(tx *bolt.Tx) error {
		outgoing := tx.Bucket([]byte("outgoing"))
		k, data := outgoing.Cursor().First()
		if k == nil {
			return nil
		}
		wireMsg = data
		msgBucket := tx.Bucket([]byte("messages"))
		data = msgBucket.Get(k)
		msg = new(Message)
		return json.Unmarshal(data, msg)
	})
}

func (s *Store) DeleteOutgoing(id uint64) error {
	return s.db.Update(func(tx *bolt.Tx) error {
		outgoing := tx.Bucket([]byte("outgoing"))
		k := itob(id)
		if err := outgoing.Delete(k); err != nil {
			return err
		}
		msgBucket := tx.Bucket([]byte("messages"))
		data := msgBucket.Get(k)
		msg := new(Message)
		if err := json.Unmarshal(data, msg); err != nil {
			return err
		}
		msg.Sent = true
		data, err := json.Marshal(msg)
		if err != nil {
			return err
		}
		return msgBucket.Put(k, data)
	})
}

func (s *Store) ThreadsByDate() ([]*Thread, error) {
	var all []*Thread
	return all, s.db.View(func(tx *bolt.Tx) error {
		sortedThreads := tx.Bucket([]byte("sortedThreads"))
		threads := tx.Bucket([]byte("threads"))
		c := sortedThreads.Cursor()
		for k, v := c.Last(); k != nil; k, v = c.Prev() {
			thread := threads.Bucket(v)
			header := s.getHeader(thread)
			all = append(all, header)
		}
		return nil
	})
}

func (s *Store) ThreadsByPrefix(prefix string) ([]*Thread, error) {
	p := []byte(prefix)
	var all []*Thread
	return all, s.db.View(func(tx *bolt.Tx) error {
		threads := tx.Bucket([]byte("threads"))
		c := threads.Cursor()
		for k, _ := c.Seek(p); k != nil && bytes.HasPrefix(k, p); k, _ = c.Next() {
			header := s.getHeader(threads.Bucket(k))
			all = append(all, header)
		}
		return nil
	})
}

func (s *Store) Close() error {
	return s.db.Close()
}

func (s *Store) putHeader(thread *bolt.Bucket, header *Thread) error {
	data, err := json.Marshal(header)
	if err != nil {
		return err
	}
	return thread.Put([]byte("header"), data)
}

func (s *Store) getHeader(thread *bolt.Bucket) *Thread {
	var header Thread
	if thread == nil {
		return &header
	}
	data := thread.Get([]byte("header"))
	if data == nil {
		return &header
	}
	err := json.Unmarshal(data, &header)
	if err != nil {
		// We wrote the header, so it cannot not be illformed.
		panic(err)
	}
	return &header
}

func itob(v uint64) []byte {
	b := make([]byte, 8)
	binary.LittleEndian.PutUint64(b, uint64(v))
	return b
}

A  => cmd/scatter/ui.go +1410 -0
@@ 1,1410 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main

import (
	"fmt"
	"image"
	"image/color"
	"log"
	"math"
	"os"
	"runtime"
	"strings"
	"time"

	"golang.org/x/image/draw"

	"gioui.org/ui"
	"gioui.org/ui/app"
	gdraw "gioui.org/ui/draw"
	"gioui.org/ui/f32"
	"gioui.org/ui/gesture"
	"gioui.org/ui/input"
	"gioui.org/ui/key"
	"gioui.org/ui/layout"
	"gioui.org/ui/measure"
	"gioui.org/ui/pointer"
	"gioui.org/ui/system"
	"gioui.org/ui/text"
	"gioui.org/ui/widget"
	"golang.org/x/exp/shiny/iconvg"

	"golang.org/x/image/font/gofont/gobold"
	"golang.org/x/image/font/gofont/goitalic"
	"golang.org/x/image/font/gofont/gomono"
	"golang.org/x/image/font/gofont/goregular"
	"golang.org/x/image/font/sfnt"

	"golang.org/x/exp/shiny/materialdesign/icons"
)

type Env struct {
	cfg    *app.Config
	insets app.Insets
	inputs input.Queue
	faces  *measure.Faces
	client *Client
	redraw func()
}

type App struct {
	env *Env
	w   *app.Window

	stack pageStack

	// Profiling.
	profiling   bool
	profile     system.ProfileEvent
	lastMallocs uint64
}

type pageStack struct {
	pages    []Page
	stopChan chan<- struct{}
}

type Page interface {
	Start(stop <-chan struct{})
	Event() interface{}
	Layout(ops *ui.Ops, cs layout.Constraints) layout.Dimens
}

type signInPage struct {
	env     *Env
	account *Account
	list    *layout.List
	fields  []*formField
	submit  *Button
}

type Button struct {
	Label string
	click gesture.Click
}

type Topbar struct {
	Back bool

	backClick gesture.Click
	stack     layout.Stack
	insets    layout.Inset
	insets2   layout.Inset
	flex      layout.Flex
	backChild layout.FlexChild
	bg        layout.StackChild
}

type formField struct {
	env    *Env
	Header string
	Hint   string
	Value  *string
	edit   *text.Editor
}

type threadsPage struct {
	env     *Env
	account *Account

	fab *IconButton

	updates       <-chan struct{}
	threadUpdates chan []*Thread

	list *layout.List

	threads []*Thread
	clicks  []gesture.Click
}

type threadPage struct {
	env       *Env
	checkmark *icon
	thread    *Thread
	list      *layout.List
	messages  []*Message
	result    chan []*Message
	msgEdit   *text.Editor
	send      *IconButton
	invite    *Button
	accept    *Button
	topbar    *Topbar
	updates   <-chan struct{}
}

type contactsPage struct {
	env        *Env
	list       *layout.List
	searchEdit *text.Editor
	contacts   []*Contact
	clicks     []gesture.Click
	query      chan []*Contact
	topbar     *Topbar
}

type Contact struct {
	Address string
}

type icon struct {
	src  []byte
	size ui.Value

	// Cached values.
	img     image.Image
	imgSize int
}

type IconButton struct {
	Icon  *icon
	Inset layout.Inset
	click gesture.Click
}

type BackEvent struct{}

type SignInEvent struct {
	Account *Account
}

type NewThreadEvent struct {
	Address string
}

type ShowContactsEvent struct{}

type ShowThreadEvent struct {
	Thread string
}

var fonts struct {
	regular *sfnt.Font
	bold    *sfnt.Font
	italic  *sfnt.Font
	mono    *sfnt.Font
}

var theme struct {
	text     ui.MacroOp
	tertText ui.MacroOp
	brand    ui.MacroOp
	white    ui.MacroOp
}

func uiMain() {
	app.Main()
}

func init() {
	fonts.regular = mustLoadFont(goregular.TTF)
	fonts.bold = mustLoadFont(gobold.TTF)
	fonts.italic = mustLoadFont(goitalic.TTF)
	fonts.mono = mustLoadFont(gomono.TTF)
	var ops ui.Ops
	theme.text = colorMaterial(&ops, rgb(0x000000))
	theme.tertText = colorMaterial(&ops, rgb(0xbbbbbb))
	theme.brand = colorMaterial(&ops, rgb(0x3c98c6))
	theme.white = colorMaterial(&ops, rgb(0xffffff))
	go func() {
		w := app.NewWindow(&app.WindowOptions{
			Width:  ui.Dp(400),
			Height: ui.Dp(800),
			Title:  "Scatter",
		})
		if err := newApp(w).run(); err != nil {
			log.Fatal(err)
		}
	}()
}

func colorMaterial(ops *ui.Ops, color color.RGBA) ui.MacroOp {
	var mat ui.MacroOp
	mat.Record(ops)
	gdraw.ColorOp{Color: color}.Add(ops)
	mat.Stop()
	return mat
}

func (a *App) run() error {
	updates := a.env.client.register(a)
	defer a.env.client.unregister(a)
	ops := new(ui.Ops)
	for {
		select {
		case <-updates:
			if err := a.env.client.Err(); err != nil {
				log.Printf("client err: %v", err)
				a.stack.Clear(newSignInPage(a.env))
			}
			a.w.Redraw()
		case e := <-a.w.Events():
			switch e := e.(type) {
			case key.ChordEvent:
				switch e.Name {
				case key.NameEscape:
					if a.stack.Len() > 1 {
						a.stack.Pop()
						a.w.Redraw()
					} else {
						os.Exit(0)
					}
				case 'P':
					if e.Modifiers&key.ModCommand != 0 {
						a.profiling = !a.profiling
						a.w.Redraw()
					}
				}
			case app.DestroyEvent:
				return e.Err
			case app.StageEvent:
				if e.Stage >= app.StageRunning && a.stack.Len() == 0 {
					a.stack.Push(newThreadsPage(a.env))
				}
			case *app.CommandEvent:
				switch e.Type {
				case app.CommandBack:
					if a.stack.Len() > 1 {
						a.stack.Pop()
						e.Cancel = true
						a.w.Redraw()
					}
				}
			case app.DrawEvent:
				ops.Reset()
				*a.env.cfg = e.Config
				a.env.insets = e.Insets
				cs := layout.RigidConstraints(e.Size)
				a.Layout(ops, cs)
				if a.profiling {
					a.layoutTimings(ops, cs)
				}
				a.w.Draw(ops)
				a.env.faces.Frame()
			}
		}
	}
}

func newApp(w *app.Window) *App {
	a := &App{
		env: &Env{
			client: getClient(),
			cfg:    new(app.Config),
			inputs: w.Queue(),
			redraw: w.Redraw,
		},
		w: w,
	}
	a.env.faces = &measure.Faces{Config: a.env.cfg}
	return a
}

func (s *pageStack) Len() int {
	return len(s.pages)
}

func (s *pageStack) Current() Page {
	return s.pages[len(s.pages)-1]
}

func (s *pageStack) Pop() {
	s.stop()
	i := len(s.pages) - 1
	s.pages[i] = nil
	s.pages = s.pages[:i]
	if len(s.pages) > 0 {
		s.start()
	}
}

func (s *pageStack) start() {
	stop := make(chan struct{})
	s.stopChan = stop
	s.Current().Start(stop)
}

func (s *pageStack) Push(p Page) {
	if s.stopChan != nil {
		s.stop()
	}
	s.pages = append(s.pages, p)
	s.start()
}

func (s *pageStack) stop() {
	close(s.stopChan)
	s.stopChan = nil
}

func (s *pageStack) Clear(p Page) {
	for len(s.pages) > 0 {
		s.Pop()
	}
	s.Push(p)
}

func mustLoadFont(fontData []byte) *sfnt.Font {
	fnt, err := sfnt.Parse(fontData)
	if err != nil {
		panic("failed to load font")
	}
	return fnt
}

func rgb(c uint32) color.RGBA {
	return argb((0xff << 24) | c)
}

func argb(c uint32) color.RGBA {
	return color.RGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)}
}

func (a *App) Layout(ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	a.update()
	return a.stack.Current().Layout(ops, cs)
}

func (a *App) layoutTimings(ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	for _, e := range a.env.inputs.Events(a) {
		if e, ok := e.(system.ProfileEvent); ok {
			a.profile = e
		}
	}

	system.ProfileOp{Key: a}.Add(ops)
	c := a.env.cfg
	var mstats runtime.MemStats
	runtime.ReadMemStats(&mstats)
	mallocs := mstats.Mallocs - a.lastMallocs
	a.lastMallocs = mstats.Mallocs
	al := layout.Align{Alignment: layout.NE}
	cs = al.Begin(ops, cs)
	in := layout.Inset{Top: ui.Dp(16)}
	in2 := layout.Inset{Top: a.env.insets.Top}
	cs = in.Begin(c, ops, cs)
	cs = in2.Begin(c, ops, cs)
	txt := fmt.Sprintf("m: %d %s", mallocs, a.profile.Timings)
	dims := text.Label{Material: theme.text, Face: a.env.faces.For(fonts.mono, ui.Sp(10)), Text: txt}.Layout(ops, cs)
	dims = in2.End(dims)
	dims = in.End(dims)
	return al.End(dims)
}

func newContactsPage(env *Env) *contactsPage {
	p := &contactsPage{
		env: env,
		list: &layout.List{
			Config: env.cfg,
			Inputs: env.inputs,
			Axis:   layout.Vertical,
		},
		searchEdit: &text.Editor{
			Config:       env.cfg,
			Inputs:       env.inputs,
			Face:         env.faces.For(fonts.regular, ui.Sp(20)),
			SingleLine:   true,
			Submit:       true,
			Hint:         "Email address",
			Material:     theme.white,
			HintMaterial: theme.tertText,
		},
		topbar: &Topbar{
			Back: true,
		},
	}
	p.searchEdit.Focus()
	return p
}

func (p *contactsPage) Start(stop <-chan struct{}) {}

func (p *contactsPage) Event() interface{} {
	for {
		e, ok := p.searchEdit.Next()
		if !ok {
			break
		}
		switch e.(type) {
		case text.ChangeEvent:
			p.queryContacts(p.searchEdit.Text())
		case text.SubmitEvent:
			if t := p.searchEdit.Text(); isEmailAddress(t) {
				return NewThreadEvent{Address: t}
			}
		}
	}
	select {
	case p.contacts = <-p.query:
		p.clicks = make([]gesture.Click, len(p.contacts))
	default:
	}
	for i := range p.clicks {
		for _, e := range p.clicks[i].Events(p.env.inputs) {
			if e.Type == gesture.TypeClick {
				return NewThreadEvent{p.contacts[i].Address}
			}
		}
	}
	return p.topbar.Event(p.env.inputs)
}

func isEmailAddress(e string) bool {
	idx := strings.Index(e, "@")
	return idx > 0 && idx < len(e)-1
}

func (p *contactsPage) queryContacts(q string) {
	p.query = make(chan []*Contact, 1)
	go func() {
		var contacts []*Contact
		if isEmailAddress(q) {
			contacts = append(contacts, &Contact{Address: q})
		}
		threads, err := p.env.client.QueryThreads(q)
		if err == nil {
			for _, t := range threads {
				contacts = append(contacts, &Contact{Address: t.ID})
			}
		} else {
			log.Printf("queryContacts: failed to query threads: %v", err)
		}
		p.query <- contacts
		p.env.redraw()
	}()
}

func (p *contactsPage) Layout(ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	c := p.env.cfg
	for e := p.Event(); e != nil; e = p.Event() {
	}
	l := p.list
	if l.Dragging() {
		key.HideInputOp{}.Add(ops)
	}
	f := layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start}
	f.Init(ops, cs)
	var dims layout.Dimens
	cs = f.Rigid()
	{
		cs = p.topbar.Begin(p.env, ops, cs)
		dims = p.searchEdit.Layout(ops, cs)
		dims = p.topbar.End(dims)
	}
	c1 := f.End(dims)
	cs = f.Flexible(1)
	cs.Height.Min = cs.Height.Max
	sysInset := layout.Inset{
		Left:  p.env.insets.Left,
		Right: p.env.insets.Right,
	}
	cs = sysInset.Begin(c, ops, cs)
	for l.Init(ops, cs, len(p.contacts)); l.More(); l.Next() {
		l.Elem(p.contact(ops, l.Constraints(), l.Index()))
	}
	dims = l.Layout()
	dims = sysInset.End(dims)
	c2 := f.End(dims)
	return f.Layout(c1, c2)
}

func (p *contactsPage) contact(ops *ui.Ops, cs layout.Constraints, index int) layout.Dimens {
	contact := p.contacts[index]
	click := &p.clicks[index]
	c := p.env.cfg
	in := layout.UniformInset(ui.Dp(8))
	cs = in.Begin(c, ops, cs)
	f := (&layout.Flex{CrossAxisAlignment: layout.Center}).Init(ops, cs)
	var dims layout.Dimens
	{
		cs = f.Rigid()
		in := layout.Inset{Right: ui.Dp(8)}
		cc := clipCircle{}
		cs = cc.Begin(ops, in.Begin(c, ops, cs))
		sz := image.Point{X: c.Px(ui.Dp(48)), Y: c.Px(ui.Dp(48))}
		cs = layout.RigidConstraints(cs.Constrain(sz))
		dims = fill{theme.brand}.Layout(ops, cs)
		dims = in.End(cc.End(dims))
	}
	c1 := f.End(dims)
	{
		cs = f.Flexible(1)
		dims = text.Label{Material: theme.text, Face: p.env.faces.For(fonts.regular, ui.Sp(18)), Text: contact.Address}.Layout(ops, cs)
	}
	c2 := f.End(dims)

	dims = f.Layout(c1, c2)
	dims = in.End(dims)
	pointer.RectAreaOp{Size: dims.Size}.Add(ops)
	click.Add(ops)
	return dims
}

func (t *Topbar) Event(inputs input.Queue) interface{} {
	for _, e := range t.backClick.Events(inputs) {
		if e.Type == gesture.TypeClick {
			return BackEvent{}
		}
	}
	return nil
}

func (t *Topbar) Begin(env *Env, ops *ui.Ops, cs layout.Constraints) layout.Constraints {
	c := env.cfg
	t.stack = layout.Stack{Alignment: layout.W}
	topInset := env.insets.Top
	t.insets2 = layout.Inset{
		Top:   topInset,
		Left:  env.insets.Left,
		Right: env.insets.Right,
	}
	t.stack.Init(ops, cs)
	cs = t.stack.Rigid()
	if h := c.Px(topInset) + c.Px(ui.Dp(56)); h < cs.Height.Max {
		cs.Height.Max = h
	}
	dims := fill{theme.brand}.Layout(ops, cs)
	t.bg = t.stack.End(dims)
	cs = t.stack.Rigid()
	cs = t.insets2.Begin(c, ops, cs)
	t.flex = layout.Flex{CrossAxisAlignment: layout.Center}
	t.flex.Init(ops, cs)
	cs = t.flex.Rigid()
	dims = layout.Dimens{}
	t.insets = layout.UniformInset(ui.Dp(12))
	if t.Back {
		t.insets.Left = ui.Px(0)
		ico := (&icon{src: icons.NavigationArrowBack, size: ui.Dp(24)}).image(c, rgb(0xffffff))
		in := layout.UniformInset(ui.Dp(8))
		cs = in.Begin(c, ops, cs)
		dims = widget.Image{Src: ico, Rect: ico.Bounds(), Scale: 1}.Layout(c, ops, cs)
		dims = in.End(dims)
		pointer.RectAreaOp{Size: dims.Size}.Add(ops)
		t.backClick.Add(ops)
	}
	t.backChild = t.flex.End(dims)
	cs = t.flex.Flexible(1)
	return t.insets.Begin(c, ops, cs)
}

func (t *Topbar) End(dims layout.Dimens) layout.Dimens {
	dims = t.insets.End(dims)
	content := t.flex.End(dims)
	dims = t.flex.Layout(t.backChild, content)
	dims = t.insets2.End(dims)
	stackContent := t.stack.End(dims)
	return t.stack.Layout(t.bg, stackContent)
}

func newSignInPage(env *Env) *signInPage {
	acc := env.client.Account()
	p := &signInPage{
		env:     env,
		account: acc,
		list: &layout.List{
			Config: env.cfg,
			Inputs: env.inputs,
			Axis:   layout.Vertical,
		},
		fields: []*formField{
			&formField{Header: "Email address", Hint: "you@example.org", Value: &acc.User},
			&formField{Header: "Password", Hint: "correct horse battery staple", Value: &acc.Password},
			&formField{Header: "IMAP host", Hint: "imap.gmail.com:993", Value: &acc.IMAPHost},
			&formField{Header: "SMTP host", Hint: "smtp.gmail.com:587", Value: &acc.SMTPHost},
		},
		submit: &Button{
			Label: "Sign in",
		},
	}
	for _, f := range p.fields {
		f.env = p.env
		f.edit = &text.Editor{
			Config:       env.cfg,
			Inputs:       env.inputs,
			Face:         env.faces.For(fonts.regular, ui.Sp(16)),
			SingleLine:   true,
			Hint:         f.Hint,
			Material:     theme.text,
			HintMaterial: theme.tertText,
		}
		f.edit.SetText(*f.Value)
	}
	return p
}

func (p *signInPage) Start(stop <-chan struct{}) {
}

func (p *signInPage) Event() interface{} {
	for _, e := range p.submit.Events(p.env.inputs) {
		if e.Type == gesture.TypeClick {
			for _, f := range p.fields {
				*f.Value = f.edit.Text()
			}
			return SignInEvent{p.account}
		}
	}
	return nil
}

func (p *signInPage) Layout(ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	c := p.env.cfg
	var dims layout.Dimens
	f := layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start}
	f.Init(ops, cs)

	cs = f.Rigid()
	{
		var t Topbar
		cs = t.Begin(p.env, ops, cs)
		dims = text.Label{Material: colorMaterial(ops, rgb(0xffffff)), Face: p.env.faces.For(fonts.regular, ui.Sp(16)), Text: "Sign in"}.Layout(ops, cs)
		dims = t.End(dims)
	}
	c1 := f.End(dims)

	cs = f.Flexible(1)
	sysInset := layout.Inset{
		Left:   p.env.insets.Left,
		Right:  p.env.insets.Right,
		Bottom: p.env.insets.Bottom,
	}
	cs = sysInset.Begin(c, ops, cs)
	dims = p.layoutSigninForm(ops, cs)
	dims = sysInset.End(dims)
	c2 := f.End(dims)
	return f.Layout(c1, c2)
}

func (p *signInPage) layoutSigninForm(ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	c := p.env.cfg
	l := p.list
	for l.Init(ops, cs, len(p.fields)+1); l.More(); l.Next() {
		in := layout.Inset{Left: ui.Dp(32), Right: ui.Dp(32)}
		var dims layout.Dimens
		switch {
		case l.Index() < len(p.fields):
			in.Bottom = ui.Dp(12)
			if l.Index() == 0 {
				in.Top = ui.Dp(32)
			}
			cs = in.Begin(c, ops, l.Constraints())
			dims = p.fields[l.Index()].Layout(ops, cs)
			dims = in.End(dims)
		default:
			in.Bottom = ui.Dp(32)
			align := layout.Align{Alignment: layout.E}
			cs = in.Begin(c, ops, align.Begin(ops, cs))
			dims = p.submit.Layout(p.env, ops, cs)
			dims = align.End(in.End(dims))
		}
		l.Elem(dims)
	}
	return l.Layout()
}

func (f *formField) Layout(ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	c := f.env.cfg
	theme.text.Add(ops)
	fl := (&layout.Flex{Axis: layout.Vertical}).Init(ops, cs)

	header := text.Label{Material: theme.text, Text: f.Header, Face: f.env.faces.For(fonts.bold, ui.Sp(12))}
	cs = fl.Rigid()
	cs.Width.Min = cs.Width.Max
	dims := header.Layout(ops, cs)
	dims.Size.Y += c.Px(ui.Dp(4))
	c1 := fl.End(dims)
	c2 := fl.End(f.edit.Layout(ops, fl.Rigid()))
	dims = fl.Layout(c1, c2)
	return dims
}

func (b *Button) Events(inputs input.Queue) []gesture.ClickEvent {
	return b.click.Events(inputs)
}

func (b *Button) Layout(env *Env, ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	c := env.cfg
	bg := Background{
		Material: theme.brand,
		Radius:   ui.Dp(4),
		Inset:    layout.UniformInset(ui.Dp(8)),
	}
	cs = bg.Begin(c, ops, cs)
	lbl := text.Label{Material: theme.white, Face: env.faces.For(fonts.regular, ui.Sp(16)), Text: b.Label, Alignment: text.Center}
	dims := lbl.Layout(ops, cs)
	dims = bg.End(dims)
	pointer.RectAreaOp{Size: dims.Size}.Add(ops)
	b.click.Add(ops)
	return dims
}

type Background struct {
	Material ui.MacroOp
	Radius   ui.Value
	Inset    layout.Inset

	macro ui.MacroOp
	c     ui.Config
	ops   *ui.Ops
}

func (b *Background) Begin(c ui.Config, ops *ui.Ops, cs layout.Constraints) layout.Constraints {
	b.c = c
	b.ops = ops
	b.macro.Record(ops)
	return b.Inset.Begin(c, ops, cs)
}

func (b *Background) End(dims layout.Dimens) layout.Dimens {
	dims = b.Inset.End(dims)
	b.macro.Stop()
	var stack ui.StackOp
	stack.Push(b.ops)
	w, h := float32(dims.Size.X), float32(dims.Size.Y)
	if r := float32(b.c.Px(b.Radius)); r > 0 {
		if r > w/2 {
			r = w / 2
		}
		if r > h/2 {
			r = h / 2
		}
		rrect(b.ops, w, h, r, r, r, r)
	}
	b.Material.Add(b.ops)
	gdraw.DrawOp{Rect: f32.Rectangle{Max: f32.Point{X: w, Y: h}}}.Add(b.ops)
	b.macro.Add(b.ops)
	stack.Pop()
	return dims
}

func newThreadsPage(env *Env) *threadsPage {
	return &threadsPage{
		env: env,
		list: &layout.List{
			Config: env.cfg,
			Inputs: env.inputs,
			Axis:   layout.Vertical,
		},
		fab: &IconButton{
			Icon:  &icon{src: icons.ContentCreate, size: ui.Dp(24)},
			Inset: layout.UniformInset(ui.Dp(16)),
		},
	}
}

func (p *threadsPage) Start(stop <-chan struct{}) {
	p.account = p.env.client.Account()
	p.fetchThreads()
	p.updates = p.env.client.register(p)
	go func() {
		<-stop
		p.env.client.unregister(p)
	}()
}

func (p *threadsPage) Event() interface{} {
	select {
	case <-p.updates:
		p.fetchThreads()
	case threads := <-p.threadUpdates:
		p.threads = threads
		p.clicks = make([]gesture.Click, len(threads))
		p.env.redraw()
	default:
	}
	for _, e := range p.fab.Events(p.env.inputs) {
		if e.Type == gesture.TypeClick {
			return ShowContactsEvent{}
		}
	}
	for i := range p.clicks {
		click := &p.clicks[i]
		for _, e := range click.Events(p.env.inputs) {
			if e.Type == gesture.TypeClick {
				t := p.threads[i]
				return ShowThreadEvent{Thread: t.ID}
			}
		}
	}
	return nil
}

func (p *threadsPage) fetchThreads() {
	p.threadUpdates = make(chan []*Thread, 1)
	go func() {
		threads, err := p.env.client.Threads()
		if err != nil {
			log.Printf("scatter: failed to load threads: %v", err)
			return
		}
		p.threadUpdates <- threads
		p.env.redraw()
	}()
}

func (p *threadsPage) Layout(ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	c := p.env.cfg
	st := layout.Stack{Alignment: layout.Center}
	st.Init(ops, cs)

	sysInset := layout.Inset{
		Left:  p.env.insets.Left,
		Right: p.env.insets.Right,
	}
	var dims layout.Dimens
	cs = st.Rigid()
	{
		f := layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start}
		f.Init(ops, cs)

		cs = f.Rigid()
		{
			var t Topbar
			cs = t.Begin(p.env, ops, cs)
			dims = text.Label{Material: theme.white, Face: p.env.faces.For(fonts.regular, ui.Sp(20)), Text: "Scatter - " + p.account.User}.Layout(ops, cs)
			dims = t.End(dims)
		}
		c3 := f.End(dims)

		cs = f.Flexible(1)
		cs = sysInset.Begin(c, ops, cs)
		dims = p.layoutThreads(ops, cs)
		dims = sysInset.End(dims)
		c4 := f.End(dims)
		dims = f.Layout(c3, c4)
	}
	c1 := st.End(dims)
	cs = st.Rigid()
	sysInset.Bottom = p.env.insets.Bottom
	cs = sysInset.Begin(c, ops, cs)
	al := layout.Align{Alignment: layout.SE}
	in := layout.UniformInset(ui.Dp(16))
	cs = in.Begin(c, ops, al.Begin(ops, cs))
	dims = p.fab.Layout(p.env, ops, cs)
	dims = al.End(in.End(dims))
	dims = sysInset.End(dims)
	c2 := st.End(dims)
	dims = st.Layout(c1, c2)
	return dims
}

func (p *threadsPage) layoutThreads(ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	l := p.list
	if l.Dragging() {
		key.HideInputOp{}.Add(ops)
	}
	for l.Init(ops, cs, len(p.threads)); l.More(); l.Next() {
		l.Elem(p.thread(ops, l.Constraints(), l.Index()))
	}
	dims := l.Layout()
	return dims
}

func (p *threadsPage) thread(ops *ui.Ops, cs layout.Constraints, index int) layout.Dimens {
	c := p.env.cfg
	t := p.threads[index]
	bgtexmat := theme.tertText
	font := fonts.regular
	if t.Unread > 0 {
		bgtexmat = theme.text
		font = fonts.bold
	}
	click := &p.clicks[index]
	elem := layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.Start}
	elem.Init(ops, cs)
	cs = elem.Rigid()
	var dims layout.Dimens
	{
		in := layout.UniformInset(ui.Dp(8))
		cs = in.Begin(c, ops, cs)
		f := centerRowOpts()
		f.Init(ops, cs)
		cs = f.Rigid()
		{
			in := layout.Inset{Right: ui.Dp(12), Left: ui.Dp(4)}
			cc := clipCircle{}
			cs = cc.Begin(ops, in.Begin(c, ops, cs))
			sz := image.Point{X: c.Px(ui.Dp(48)), Y: c.Px(ui.Dp(48))}
			cs = layout.RigidConstraints(cs.Constrain(sz))
			dims = fill{theme.brand}.Layout(ops, cs)
			dims = in.End(cc.End(dims))
		}
		c1 := f.End(dims)
		cs = f.Rigid()
		{
			f := column()
			f.Init(ops, cs)
			cs = f.Rigid()
			{
				f := baseline()
				f.Init(ops, cs)
				cs = f.Rigid()
				dims = text.Label{Material: theme.text, Face: p.env.faces.For(font, ui.Sp(18)), Text: t.ID}.Layout(ops, cs)
				c1 := f.End(dims)
				cs = f.Flexible(1)
				cs.Width.Min = cs.Width.Max
				al := layout.Align{Alignment: layout.E}
				in := layout.Inset{Left: ui.Dp(2)}
				cs = in.Begin(c, ops, al.Begin(ops, cs))
				dims = text.Label{Material: bgtexmat, Face: p.env.faces.For(font, ui.Sp(12)), Text: formatTime(t.Updated)}.Layout(ops, cs)
				dims = al.End(in.End(dims))
				c2 := f.End(dims)
				dims = f.Layout(c1, c2)
			}
			c1 := f.End(dims)
			cs = f.Rigid()
			in := layout.Inset{Top: ui.Dp(6)}
			cs = in.Begin(c, ops, cs)
			dims = text.Label{Material: bgtexmat, Face: p.env.faces.For(font, ui.Sp(14)), MaxLines: 1, Text: t.Snippet}.Layout(ops, cs)
			dims = in.End(dims)
			c2 := f.End(dims)
			dims = f.Layout(c1, c2)
		}
		c2 := f.End(dims)
		dims = f.Layout(c1, c2)
		dims = in.End(dims)
		pointer.RectAreaOp{Size: dims.Size}.Add(ops)
		click.Add(ops)
	}
	c1 := elem.End(dims)
	return elem.Layout(c1)
}

func newThreadPage(env *Env, threadID string) *threadPage {
	thread := env.client.Thread(threadID)
	return &threadPage{
		env:       env,
		thread:    thread,
		checkmark: &icon{src: icons.ActionDone, size: ui.Dp(12)},
		list: &layout.List{
			Config: env.cfg,
			Inputs: env.inputs,
			Axis:   layout.Vertical,
			Invert: true,
		},
		result: make(chan []*Message, 1),
		msgEdit: &text.Editor{
			Config:       env.cfg,
			Inputs:       env.inputs,
			Face:         env.faces.For(fonts.regular, ui.Sp(14)),
			Submit:       true,
			Hint:         "Send a message",
			Material:     theme.text,
			HintMaterial: theme.tertText,
		},
		send: &IconButton{
			Icon:  &icon{src: icons.ContentSend, size: ui.Dp(24)},
			Inset: layout.UniformInset(ui.Dp(6)),
		},
		invite: &Button{
			Label: "Send invitation",
		},
		accept: &Button{
			Label: "Accept invitation",
		},
		topbar: &Topbar{
			Back: true,
		},
	}
}

func (p *threadPage) Start(stop <-chan struct{}) {
	p.fetchMessages()
	p.updates = p.env.client.register(p)
	p.env.client.MarkRead(p.thread.ID)
	go func() {
		<-stop
		p.env.client.unregister(p)
	}()
}

func (p *threadPage) Event() interface{} {
	select {
	case <-p.updates:
		p.fetchMessages()
	default:
	}
	for e, ok := p.msgEdit.Next(); ok; e, ok = p.msgEdit.Next() {
		if _, ok := e.(text.SubmitEvent); ok {
			p.sendMessage()
		}
	}
	for _, e := range p.send.Events(p.env.inputs) {
		if e.Type == gesture.TypeClick {
			p.sendMessage()
		}
	}
	for _, e := range p.invite.Events(p.env.inputs) {
		if e.Type == gesture.TypeClick {
			if err := p.env.client.Send(p.thread.ID, "Invitation sent"); err != nil {
				log.Printf("failed to send invitation: %v", err)
			}
			break
		}
	}
	for _, e := range p.accept.Events(p.env.inputs) {
		if e.Type == gesture.TypeClick {
			if err := p.env.client.Send(p.thread.ID, "Invitation accepted"); err != nil {
				log.Printf("failed to send invitation accept: %v", err)
			}
			break
		}
	}
	return p.topbar.Event(p.env.inputs)
}

func (p *threadPage) sendMessage() {
	if t := p.msgEdit.Text(); t != "" {
		if err := p.env.client.Send(p.thread.ID, t); err != nil {
			log.Printf("failed to send message: %v", err)
		}
		p.msgEdit.SetText("")
	}
}

func (p *threadPage) Layout(ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	c := p.env.cfg
	l := p.list
	if l.Dragging() {
		key.HideInputOp{}.Add(ops)
	}
	select {
	case p.messages = <-p.result:
	default:
	}
	var dims layout.Dimens
	f := layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start}
	f.Init(ops, cs)
	{
		cs = f.Rigid()
		cs = p.topbar.Begin(p.env, ops, cs)
		dims = text.Label{Material: theme.white, Face: p.env.faces.For(fonts.regular, ui.Sp(20)), Text: p.thread.ID}.Layout(ops, cs)
		dims = p.topbar.End(dims)
	}
	c1 := f.End(dims)

	sysInset := layout.Inset{
		Left:  p.env.insets.Left,
		Right: p.env.insets.Right,
	}
	{
		cs = f.Rigid()
		sysInset := sysInset
		sysInset.Bottom = p.env.insets.Bottom
		cs = sysInset.Begin(c, ops, cs)
		in := layout.Inset{Top: ui.Dp(8), Bottom: ui.Dp(8), Left: ui.Dp(12), Right: ui.Dp(12)}
		cs = in.Begin(c, ops, cs)
		switch {
		case p.thread.PendingInvitation:
			dims = p.accept.Layout(p.env, ops, cs)
		case p.env.client.ContainsSession(p.thread.ID):
			dims = p.layoutMessageBox(ops, cs)
		default:
			dims = p.invite.Layout(p.env, ops, cs)
		}
		dims = in.End(dims)
		dims = sysInset.End(dims)
	}
	c3 := f.End(dims)

	{
		cs = f.Flexible(1)
		cs.Height.Min = cs.Height.Max
		cs = sysInset.Begin(c, ops, cs)
		for l.Init(ops, cs, len(p.messages)); l.More(); l.Next() {
			l.Elem(p.message(ops, l.Constraints(), l.Index()))
		}
		dims = l.Layout()
		dims = sysInset.End(dims)
	}
	c2 := f.End(dims)
	return f.Layout(c1, c2, c3)
}

func (p *threadPage) layoutMessageBox(ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	c := p.env.cfg
	if mh := c.Px(ui.Dp(100)); cs.Height.Max > mh {
		cs.Height.Max = mh
	}
	f := (&layout.Flex{CrossAxisAlignment: layout.End}).Init(ops, cs)

	var dims layout.Dimens
	var sendHeight int
	{
		cs = f.Rigid()
		in := layout.Inset{Left: ui.Dp(8)}
		cs = in.Begin(c, ops, cs)
		dims = p.send.Layout(p.env, ops, cs)
		sendHeight = dims.Size.Y
		dims = in.End(dims)
	}
	c2 := f.End(dims)

	{
		cs = f.Flexible(1)
		cs.Width.Min = cs.Width.Max
		if cs.Height.Min < sendHeight {
			cs.Height.Min = sendHeight
		}
		bg := Background{
			Material: colorMaterial(ops, rgb(0xeeeeee)),
			Inset:    layout.UniformInset(ui.Dp(8)),
			Radius:   ui.Dp(10),
		}
		cs = bg.Begin(c, ops, cs)
		align := layout.Align{Alignment: layout.W}
		cs = align.Begin(ops, cs)
		cs.Width.Min = cs.Width.Max
		dims = p.msgEdit.Layout(ops, cs)
		dims = align.End(dims)
		dims = bg.End(dims)
	}
	c1 := f.End(dims)
	return f.Layout(c1, c2)
}

func (p *threadPage) message(ops *ui.Ops, cs layout.Constraints, index int) layout.Dimens {
	c := p.env.cfg
	msg := p.messages[index]
	var dims layout.Dimens
	in := layout.Inset{Top: ui.Dp(8), Left: ui.Dp(8), Right: ui.Dp(40)}
	align := layout.Align{Alignment: layout.W}
	msgMat := colorMaterial(ops, rgb(0xffffff))
	bgcol := theme.brand
	timecol := argb(0xaaaaaaaa)
	if msg.Own {
		in.Left, in.Right = in.Right, in.Left
		align.Alignment = layout.E
		bgcol = colorMaterial(ops, rgb(0xeeeeee))
		msgMat = theme.text
		timecol = rgb(0x888888)
	}
	cs = in.Begin(c, ops, cs)
	{
		cs = align.Begin(ops, cs)
		bg := Background{
			Material: bgcol,
			Inset:    layout.Inset{Top: ui.Dp(8), Bottom: ui.Dp(8), Left: ui.Dp(12), Right: ui.Dp(12)},
			Radius:   ui.Dp(10),
		}
		cs = bg.Begin(c, ops, cs)
		f := layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.Start}
		f.Init(ops, cs)

		cs = f.Rigid()
		label := text.Label{Material: msgMat, Face: p.env.faces.For(fonts.regular, ui.Sp(14)), Text: msg.Message}
		dims = label.Layout(ops, cs)
		dims.Size.Y += c.Px(ui.Dp(4))
		msgWidth := dims.Size.X
		c1 := f.End(dims)

		cs = f.Rigid()
		{
			cs.Width.Min = msgWidth
			f := layout.Flex{Axis: layout.Horizontal, MainAxisAlignment: layout.SpaceBetween, CrossAxisAlignment: layout.Center}
			f.Init(ops, cs)

			cs = f.Rigid()
			time := formatTime(msg.Time)
			tlbl := text.Label{Material: colorMaterial(ops, timecol), Face: p.env.faces.For(fonts.regular, ui.Sp(10)), Text: time}
			children := []layout.FlexChild{f.End(tlbl.Layout(ops, cs))}

			if msg.Own {
				cs = f.Rigid()
				in := layout.Inset{Left: ui.Dp(12)}
				cs = in.Begin(c, ops, cs)
				checkmark := p.checkmark.image(c, timecol)
				r := checkmark.Bounds()
				if msg.Sent {
					gdraw.ImageOp{Img: checkmark, Rect: r}.Add(ops)
					gdraw.DrawOp{Rect: toRectF(r)}.Add(ops)
				}
				dims = layout.Dimens{Size: r.Size()}
				c := f.End(in.End(dims))
				children = append(children, c)
			}
			dims = f.Layout(children...)
		}
		c2 := f.End(dims)

		dims = f.Layout(c1, c2)
		dims = bg.End(dims)
		dims = align.End(dims)
	}
	return in.End(dims)
}

func (p *threadPage) fetchMessages() {
	p.thread = p.env.client.Thread(p.thread.ID)
	go func() {
		messages, err := p.env.client.Messages(p.thread.ID)
		if err != nil {
			log.Printf("scatter: failed to load messages: %v", err)
			return
		}
		p.result <- messages
		p.env.client.MarkRead(p.thread.ID)
		p.env.redraw()
	}()
}

// formatTime formats a time relative to now. For times within a
// week the date is left out.
func formatTime(t time.Time) string {
	y, m, d := t.Date()
	tday := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
	y, m, d = time.Now().Date()
	nday := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
	n := int(nday.Sub(tday) / (time.Hour * 24))
	format := "Jan _2 15:04"
	if n < 7 {
		format = "Mon 15:04"
	}
	return t.Format(format)
}

func (b *IconButton) Events(inputs input.Queue) []gesture.ClickEvent {
	return b.click.Events(inputs)
}

func (b *IconButton) Layout(env *Env, ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	c := env.cfg
	ico := b.Icon.image(c, rgb(0xffffff))
	bg := Background{
		Material: theme.brand,
		Radius:   ui.Px(float32(math.Inf(+1))),
		Inset:    b.Inset,
	}
	cs = bg.Begin(c, ops, cs)
	sz := image.Point{X: ico.Bounds().Dx(), Y: ico.Bounds().Dy()}
	cs = layout.RigidConstraints(cs.Constrain(sz))
	dims := widget.Image{Src: ico, Rect: ico.Bounds(), Scale: 1}.Layout(c, ops, cs)
	dims = bg.End(dims)
	pointer.EllipseAreaOp{Size: dims.Size}.Add(ops)
	b.click.Add(ops)
	return dims
}

func (a *App) update() {
	page := a.stack.Current()
	if e := page.Event(); e != nil {
		switch e := e.(type) {
		case BackEvent:
			a.stack.Pop()
		case SignInEvent:
			a.env.client.SetAccount(e.Account)
			a.stack.Clear(newThreadsPage(a.env))
		case NewThreadEvent:
			a.stack.Pop()
			a.stack.Push(newThreadPage(a.env, e.Address))
		case ShowContactsEvent:
			a.stack.Push(newContactsPage(a.env))
		case ShowThreadEvent:
			a.stack.Push(newThreadPage(a.env, e.Thread))
		}
	}
}

type fill struct {
	material ui.MacroOp
}

func (f fill) Layout(ops *ui.Ops, cs layout.Constraints) layout.Dimens {
	d := image.Point{X: cs.Width.Max, Y: cs.Height.Max}
	if d.X == ui.Inf {
		d.X = cs.Width.Min
	}
	if d.Y == ui.Inf {
		d.Y = cs.Height.Min
	}
	dr := f32.Rectangle{
		Max: f32.Point{X: float32(d.X), Y: float32(d.Y)},
	}
	f.material.Add(ops)
	gdraw.DrawOp{Rect: dr}.Add(ops)
	return layout.Dimens{Size: d, Baseline: d.Y}
}

func column() layout.Flex {
	return layout.Flex{Axis: layout.Vertical, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.Start}
}

func centerRowOpts() layout.Flex {
	return layout.Flex{Axis: layout.Horizontal, MainAxisAlignment: layout.Start, CrossAxisAlignment: layout.Center}
}

func baseline() layout.Flex {
	return layout.Flex{Axis: layout.Horizontal, CrossAxisAlignment: layout.Baseline}
}

type clipCircle struct {
	ops   *ui.Ops
	macro ui.MacroOp
}

func (c *clipCircle) Begin(ops *ui.Ops, cs layout.Constraints) layout.Constraints {
	c.ops = ops
	c.macro.Record(ops)
	return cs
}

func (c *clipCircle) End(dims layout.Dimens) layout.Dimens {
	ops := c.ops
	c.macro.Stop()
	max := dims.Size.X
	if dy := dims.Size.Y; dy > max {
		max = dy
	}
	szf := float32(max)
	rr := szf * .5
	var stack ui.StackOp
	stack.Push(ops)
	rrect(ops, szf, szf, rr, rr, rr, rr)
	c.macro.Add(ops)
	stack.Pop()
	return dims
}

func toRectF(r image.Rectangle) f32.Rectangle {
	return f32.Rectangle{
		Min: f32.Point{X: float32(r.Min.X), Y: float32(r.Min.Y)},
		Max: f32.Point{X: float32(r.Max.X), Y: float32(r.Max.Y)},
	}
}

func (ic *icon) image(c ui.Config, col color.RGBA) image.Image {
	sz := c.Px(ic.size)
	if sz == ic.imgSize {
		return ic.img
	}
	m, _ := iconvg.DecodeMetadata(ic.src)
	dx, dy := m.ViewBox.AspectRatio()
	img := image.NewRGBA(image.Rectangle{Max: image.Point{X: sz, Y: int(float32(sz) * dy / dx)}})
	var ico iconvg.Rasterizer
	ico.SetDstImage(img, img.Bounds(), draw.Src)
	m.Palette[0] = col
	iconvg.Decode(&ico, ic.src, &iconvg.DecodeOptions{
		Palette: &m.Palette,
	})
	ic.img = img
	ic.imgSize = sz
	return img
}

// https://pomax.github.io/bezierinfo/#circles_cubic.
func rrect(ops *ui.Ops, width, height, se, sw, nw, ne float32) {
	w, h := float32(width), float32(height)
	const c = 0.55228475 // 4*(sqrt(2)-1)/3
	var b gdraw.PathBuilder
	b.Init(ops)
	b.Move(f32.Point{X: w, Y: h - se})
	b.Cube(f32.Point{X: 0, Y: se * c}, f32.Point{X: -se + se*c, Y: se}, f32.Point{X: -se, Y: se}) // SE
	b.Line(f32.Point{X: sw - w + se, Y: 0})
	b.Cube(f32.Point{X: -sw * c, Y: 0}, f32.Point{X: -sw, Y: -sw + sw*c}, f32.Point{X: -sw, Y: -sw}) // SW
	b.Line(f32.Point{X: 0, Y: nw - h + sw})
	b.Cube(f32.Point{X: 0, Y: -nw * c}, f32.Point{X: nw - nw*c, Y: -nw}, f32.Point{X: nw, Y: -nw}) // NW
	b.Line(f32.Point{X: w - ne - nw, Y: 0})
	b.Cube(f32.Point{X: ne * c, Y: 0}, f32.Point{X: ne, Y: ne - ne*c}, f32.Point{X: ne, Y: ne}) // NE
	b.End()
}

A  => go.mod +17 -0
@@ 1,17 @@
module scatter.im

go 1.13

require (
	gioui.org/ui v0.0.0-20190716115357-b4441a8728e5
	github.com/eliasnaur/libsignal-protocol-go v0.0.0-20190626062856-3295f72b181e
	github.com/emersion/go-imap v1.0.0-rc.1
	github.com/emersion/go-imap-idle v0.0.0-20190519112320-2704abd7050e
	github.com/emersion/go-message v0.10.3
	github.com/emersion/go-sasl v0.0.0-20190520160400-47d427600317
	github.com/emersion/go-smtp v0.11.1
	github.com/stretchr/testify v1.3.0 // indirect
	go.etcd.io/bbolt v1.3.3
	golang.org/x/exp v0.0.0-20190627132806-fd42eb6b336f
	golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9
)

A  => go.sum +61 -0
@@ 1,61 @@
gioui.org/ui v0.0.0-20190716115357-b4441a8728e5 h1:qAZ/M+UgZYEXjBZZsKPUrPAMpHCelfeh9av5DOfA7DM=
gioui.org/ui v0.0.0-20190716115357-b4441a8728e5/go.mod h1:jbaSHnW/jO2+8gOhOraRz7/3A2tntNzXF9nra6FUOr4=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/RadicalApp/complete v0.0.0-20170329192659-17e6c0ee499b h1:cAULFohNVfNzco0flF4okSPg3s7/tCj+hMIldtYZo4c=
github.com/RadicalApp/complete v0.0.0-20170329192659-17e6c0ee499b/go.mod h1:zZ3+l0EkpT2ZPnoamPBG50PBUtQrXwwyJ6elQZMmqgk=
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI=
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eliasnaur/libsignal-protocol-go v0.0.0-20190626062856-3295f72b181e h1:38FAlcL9+/l+uPQpZlJw8U/7ZlIpOFcf2e4Js5kBfG0=
github.com/eliasnaur/libsignal-protocol-go v0.0.0-20190626062856-3295f72b181e/go.mod h1:m/HGQ7GFBVmFw5stJTZseOPyitDtK9WHdFlKD5wq4u8=
github.com/emersion/go-imap v1.0.0-rc.1 h1:XnHHVDsnCqJG0QxM5vTaCjl7kCvZ1KGMke2rCxndciE=
github.com/emersion/go-imap v1.0.0-rc.1/go.mod h1:ORBuwFXdwt9QrAOecJPpirG6j9mao9wMfHIkd0EZfdo=
github.com/emersion/go-imap-idle v0.0.0-20190519112320-2704abd7050e h1:L7bswVJZcf2YHofgom49oFRwVqmBj/qZqDy9/SJpZMY=
github.com/emersion/go-imap-idle v0.0.0-20190519112320-2704abd7050e/go.mod h1:o14zPKCmEH5WC1vU5SdPoZGgNvQx7zzKSnxPQlobo78=
github.com/emersion/go-message v0.10.3 h1:4pajGb3Rq+gHLfRcWysgcwtGRNgLpB8LC6X/vRZ89d0=
github.com/emersion/go-message v0.10.3/go.mod h1:3h+HsGTCFHmk4ngJ2IV/YPhdlaOcR6hcgqM3yca9v7c=
github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
github.com/emersion/go-sasl v0.0.0-20190520160400-47d427600317 h1:tYZxAY8nu3JJQKios9f27Sbvbkfm4XHXT476gVtszu0=
github.com/emersion/go-sasl v0.0.0-20190520160400-47d427600317/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
github.com/emersion/go-smtp v0.11.1 h1:2IBWhU2zjrfOOmZal3qRxVsfYnf0rN+ccImZrjnMT7E=
github.com/emersion/go-smtp v0.11.1/go.mod h1:CfUbM5NgspbOMHFEgCdoK2PVrKt48HAPtL8hnahwfYg=
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe h1:40SWqY0zE3qCi6ZrtTf5OUdNm5lDnGnjRSq9GgmeTrg=
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/martinlindhe/base36 v0.0.0-20190418230009-7c6542dfbb41 h1:CVsnY46BCLkX9XOhALJ/S7yb9ayc4eqjXSXO3tyB66A=
github.com/martinlindhe/base36 v0.0.0-20190418230009-7c6542dfbb41/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4 h1:ydJNl0ENAG67pFbB+9tfhiL2pYqLhfoaZFw/cjLhY4A=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20190627132806-fd42eb6b336f h1:F3VDpCbV+46wJMDIwbFSefCwLlvK2CoEKVEYHO8p5Os=
golang.org/x/exp v0.0.0-20190627132806-fd42eb6b336f/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9 h1:uc17S921SPw5F2gJo7slQ3aqvr2RwpL7eb3+DZncu3s=
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb h1:fgwFCsaw9buMuxNd6+DQfAuSFqbNiQZpcgJQAgJsK6k=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=

A  => website/app.yaml +9 -0
@@ 1,9 @@
# SPDX-License-Identifier: Unlicense OR MIT

runtime: go112

handlers:

- url: .*
  script: auto
  secure: always

A  => website/main.go +44 -0
@@ 1,44 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main

import (
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"
)

func main() {
	http.HandleFunc("/", vanityHandler)

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil))
}

// vanityHandler serves git location meta headers for the go tool.
func vanityHandler(w http.ResponseWriter, r *http.Request) {
	if www := "www."; strings.HasPrefix(r.URL.Host, www) {
		r.URL.Host = r.URL.Host[len(www):]
		http.Redirect(w, r, r.URL.String(), http.StatusMovedPermanently)
		return
	}
	if r.URL.Query().Get("go-get") == "1" {
		fmt.Fprintf(w, `<html><head>
<meta name="go-import" content="scatter.im git https://git.sr.ht/~eliasnaur/scatter">
<meta name="go-source" content="scatter.im https://git.sr.ht/~eliasnaur/scatter https://git.sr.ht/~eliasnaur/scatter/tree/master{/dir} https://git.sr.ht/~eliasnaur/scatter/tree/master{/dir}/{file}#L{line}">
</head></html>`)
		return
	}
	switch r.URL.Path {
	case "/":
		http.Redirect(w, r, "https://git.sr.ht/~eliasnaur/scatter", http.StatusFound)
	default:
		http.NotFound(w, r)
	}
}