// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"fmt"
"image"
"image/color"
"log"
"math"
"runtime"
"strings"
"time"
"unicode"
"golang.org/x/image/draw"
"gioui.org/app"
"gioui.org/f32"
"gioui.org/font/gofont"
"gioui.org/gesture"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/io/profile"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"golang.org/x/exp/shiny/iconvg"
"golang.org/x/exp/shiny/materialdesign/icons"
"golang.org/x/image/font/sfnt"
)
type Env struct {
insets layout.Inset
client *Client
redraw func()
}
type App struct {
w *app.Window
env Env
stack pageStack
// Profiling.
profiling bool
profile profile.Event
lastMallocs uint64
}
type Transition struct {
prev, page Page
reverse bool
time time.Time
}
type pageStack struct {
pages []Page
stopChan chan<- struct{}
}
type Page interface {
Start(stop <-chan struct{})
Event(gtx *layout.Context) interface{}
Layout(gtx *layout.Context)
}
type signInPage struct {
env *Env
account *Account
list *layout.List
fields []*formField
submit *widget.Button
}
type Topbar struct {
Back bool
backClick gesture.Click
}
type formField struct {
env *Env
Header string
Hint string
Value *string
edit *widget.Editor
}
type threadsPage struct {
env *Env
account *Account
fab *widget.Button
fabIcon *widget.Icon
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 *widget.Editor
send *widget.Button
invite *widget.Button
accept *widget.Button
topbar *Topbar
updates <-chan struct{}
}
type contactsPage struct {
env *Env
list *layout.List
searchEdit *widget.Editor
contacts []*Contact
clicks []gesture.Click
query chan []*Contact
topbar *Topbar
}
type Contact struct {
Address string
}
type icon struct {
src []byte
size unit.Value
// Cached values.
op paint.ImageOp
imgSize int
}
type BackEvent struct{}
type SignInEvent struct {
Account *Account
}
type NewThreadEvent struct {
Address string
}
type ShowContactsEvent struct{}
type ShowThreadEvent struct {
Thread string
}
var theme *material.Theme
var iconLib struct {
create *widget.Icon
send *widget.Icon
}
func uiMain() {
gofont.Register()
theme = material.NewTheme()
theme.Color.Primary = rgb(0x3c98c6)
iconLib.create = mustIcon(icons.ContentCreate)
iconLib.send = mustIcon(icons.ContentSend)
go func() {
w := app.NewWindow(
app.Size(unit.Dp(400), unit.Dp(800)),
app.Title("Scatter"),
)
if err := newApp(w).run(); err != nil {
log.Fatal(err)
}
}()
app.Main()
}
func mustIcon(data []byte) *widget.Icon {
ico, err := widget.NewIcon(data)
if err != nil {
log.Fatal(err)
}
return ico
}
func (a *App) run() error {
var updates <-chan struct{}
gtx := layout.NewContext(a.w.Queue())
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.Invalidate()
case e := <-a.w.Events():
switch e := e.(type) {
case key.Event:
switch e.Name {
case key.NameEscape:
if a.stack.Len() > 1 {
a.stack.Pop()
a.w.Invalidate()
}
case "P":
if e.Modifiers&key.ModShortcut != 0 {
a.profiling = !a.profiling
a.w.Invalidate()
}
}
case system.DestroyEvent:
return e.Err
case system.StageEvent:
if e.Stage >= system.StageRunning {
if a.env.client == nil {
a.env.client = getClient()
updates = a.env.client.register(a)
defer a.env.client.unregister(a)
}
if a.stack.Len() == 0 {
a.stack.Push(newThreadsPage(&a.env))
}
}
case *system.CommandEvent:
switch e.Type {
case system.CommandBack:
if a.stack.Len() > 1 {
a.stack.Pop()
e.Cancel = true
a.w.Invalidate()
}
}
case system.FrameEvent:
gtx.Reset(e.Config, e.Size)
a.env.insets = layout.Inset{
Top: e.Insets.Top,
Left: e.Insets.Left,
Right: e.Insets.Right,
Bottom: unit.Add(gtx, unit.Dp(8), e.Insets.Bottom),
}
a.Layout(gtx)
if a.profiling {
a.layoutTimings(gtx)
}
e.Frame(gtx.Ops)
}
}
}
}
func newApp(w *app.Window) *App {
a := &App{
w: w,
}
a.env.redraw = a.w.Invalidate
return a
}
func (t *Transition) Start(stop <-chan struct{}) {
t.page.Start(stop)
}
func (t *Transition) Event(gtx *layout.Context) interface{} {
return t.page.Event(gtx)
}
func (t *Transition) Layout(gtx *layout.Context) {
var stack op.StackOp
stack.Push(gtx.Ops)
prev, page := t.prev, t.page
if prev != nil {
if t.reverse {
prev, page = page, prev
}
now := gtx.Now()
if t.time.IsZero() {
t.time = now
}
prev.Layout(gtx)
cs := gtx.Constraints
size := f32.Point{X: float32(cs.Width.Max), Y: float32(cs.Height.Max)}
max := float32(math.Sqrt(float64(size.X*size.X + size.Y*size.Y)))
progress := float32(now.Sub(t.time).Seconds()) * 3
progress = progress * progress // Accelerate
if progress >= 1 {
// Stop animation when complete.
t.prev = nil
}
if t.reverse {
progress = 1 - progress
}
diameter := progress * max
radius := diameter / 2
op.InvalidateOp{}.Add(gtx.Ops)
center := size.Mul(.5)
clipCenter := f32.Point{X: diameter / 2, Y: diameter / 2}
off := op.TransformOp{}.Offset(center.Sub(clipCenter))
off.Add(gtx.Ops)
clip.Rect{
Rect: f32.Rectangle{Max: f32.Point{X: diameter, Y: diameter}},
NE: radius, NW: radius, SE: radius, SW: radius,
}.Op(gtx.Ops).Add(gtx.Ops)
off.Invert().Add(gtx.Ops)
fill{rgb(0xffffff)}.Layout(gtx)
}
page.Layout(gtx)
stack.Pop()
}
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
prev := s.pages[i]
s.pages[i] = nil
s.pages = s.pages[:i]
if len(s.pages) > 0 {
s.pages[i-1] = &Transition{
reverse: true,
prev: prev,
page: s.Current(),
}
s.start()
}
}
func (s *pageStack) start() {
stop := make(chan struct{})
s.stopChan = stop
s.Current().Start(stop)
}
func (s *pageStack) Swap(p Page) {
prev := s.pages[len(s.pages)-1]
s.pages[len(s.pages)-1] = &Transition{
prev: prev,
page: p,
}
s.start()
}
func (s *pageStack) Push(p Page) {
if s.stopChan != nil {
s.stop()
}
if len(s.pages) > 0 {
p = &Transition{
prev: s.Current(),
page: p,
}
}
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(gtx *layout.Context) {
a.update(gtx)
a.stack.Current().Layout(gtx)
}
func (a *App) layoutTimings(gtx *layout.Context) {
for _, e := range gtx.Events(a) {
if e, ok := e.(profile.Event); ok {
a.profile = e
}
}
profile.Op{Key: a}.Add(gtx.Ops)
var mstats runtime.MemStats
runtime.ReadMemStats(&mstats)
mallocs := mstats.Mallocs - a.lastMallocs
a.lastMallocs = mstats.Mallocs
layout.NE.Layout(gtx, func() {
in := a.env.insets
in.Top = unit.Max(gtx, unit.Dp(16), in.Top)
in.Layout(gtx, func() {
txt := fmt.Sprintf("m: %d %s", mallocs, a.profile.Timings)
lbl := material.Caption(theme, txt)
lbl.Font.Variant = "Mono"
lbl.Layout(gtx)
})
})
}
func newContactsPage(env *Env) *contactsPage {
p := &contactsPage{
env: env,
list: &layout.List{
Axis: layout.Vertical,
},
searchEdit: &widget.Editor{
SingleLine: true,
Submit: true,
},
topbar: &Topbar{
Back: true,
},
}
p.searchEdit.Focus()
return p
}
func (p *contactsPage) Start(stop <-chan struct{}) {}
func (p *contactsPage) Event(gtx *layout.Context) interface{} {
for _, e := range p.searchEdit.Events(gtx) {
switch e := e.(type) {
case widget.ChangeEvent:
p.queryContacts(p.searchEdit.Text())
case widget.SubmitEvent:
if t := e.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(gtx) {
if e.Type == gesture.TypeClick {
return NewThreadEvent{p.contacts[i].Address}
}
}
}
return p.topbar.Event(gtx)
}
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(gtx *layout.Context) {
for e := p.Event(gtx); e != nil; e = p.Event(gtx) {
}
l := p.list
if l.Dragging() {
key.HideInputOp{}.Add(gtx.Ops)
}
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func() {
p.topbar.Layout(gtx, p.env.insets, func() {
e := material.Editor(theme, "Email address")
e.TextSize = unit.Sp(20)
e.Color = rgb(0xffffff)
e.HintColor = rgb(0xbbbbbb)
e.Layout(gtx, p.searchEdit)
})
}),
layout.Flexed(1, func() {
gtx.Constraints.Height.Min = gtx.Constraints.Height.Max
l.Layout(gtx, len(p.contacts), func(i int) {
p.contact(gtx, i)
})
}),
)
}
func (p *contactsPage) contact(gtx *layout.Context, index int) {
in := layout.Inset{
Top: unit.Dp(16),
Bottom: unit.Dp(16),
Left: unit.Max(gtx, unit.Dp(16), p.env.insets.Left),
Right: unit.Max(gtx, unit.Dp(16), p.env.insets.Right),
}
contact := p.contacts[index]
click := &p.clicks[index]
in.Layout(gtx, func() {
layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func() {
in := layout.Inset{Right: unit.Dp(8)}
in.Layout(gtx, func() {
cc := clipCircle{}
cc.Layout(gtx, func() {
sz := image.Point{X: gtx.Px(unit.Dp(48)), Y: gtx.Px(unit.Dp(48))}
gtx.Constraints = layout.RigidConstraints(gtx.Constraints.Constrain(sz))
fill{theme.Color.Primary}.Layout(gtx)
})
})
}),
layout.Flexed(1, func() {
material.H6(theme, contact.Address).Layout(gtx)
}),
)
})
pointer.Rect(image.Rectangle{Max: gtx.Dimensions.Size}).Add(gtx.Ops)
click.Add(gtx.Ops)
}
func (t *Topbar) Event(gtx *layout.Context) interface{} {
for _, e := range t.backClick.Events(gtx) {
if e.Type == gesture.TypeClick {
return BackEvent{}
}
}
return nil
}
func (t *Topbar) Layout(gtx *layout.Context, insets layout.Inset, w layout.Widget) {
insets = layout.Inset{
Top: unit.Add(gtx, insets.Top, unit.Dp(16)),
Bottom: unit.Dp(16),
Left: unit.Max(gtx, insets.Left, unit.Dp(16)),
Right: unit.Max(gtx, insets.Right, unit.Dp(16)),
}
layout.Stack{Alignment: layout.SW}.Layout(gtx,
layout.Expanded(func() {
fill{theme.Color.Primary}.Layout(gtx)
}),
layout.Stacked(func() {
insets.Layout(gtx, func() {
layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func() {
if t.Back {
ico := (&icon{src: icons.NavigationArrowBack, size: unit.Dp(24)}).image(gtx, rgb(0xffffff))
ico.Add(gtx.Ops)
paint.PaintOp{Rect: f32.Rectangle{Max: toPointF(ico.Size())}}.Add(gtx.Ops)
gtx.Dimensions.Size = ico.Size()
gtx.Dimensions.Size.X += gtx.Px(unit.Dp(4))
pointer.Rect(image.Rectangle{Max: gtx.Dimensions.Size}).Add(gtx.Ops)
t.backClick.Add(gtx.Ops)
}
}),
layout.Flexed(1, w),
)
})
}),
)
}
func newSignInPage(env *Env) *signInPage {
acc := env.client.Account()
if acc.SMTPHost == "" && acc.IMAPHost == "" {
acc.SMTPHost = "smtp.gmail.com:587"
acc.IMAPHost = "imap.gmail.com:993"
}
p := &signInPage{
env: env,
account: acc,
list: &layout.List{
Axis: layout.Vertical,
},
fields: []*formField{
{Header: "Email address", Hint: "you@example.org", Value: &acc.User},
{Header: "Password", Hint: "correct horse battery staple", Value: &acc.Password},
{Header: "IMAP host", Hint: "host:port", Value: &acc.IMAPHost},
{Header: "SMTP host", Hint: "host:port", Value: &acc.SMTPHost},
},
submit: &widget.Button{},
}
for _, f := range p.fields {
f.env = p.env
f.edit = &widget.Editor{
SingleLine: true,
}
f.edit.SetText(*f.Value)
}
return p
}
func (p *signInPage) Start(stop <-chan struct{}) {
}
func (p *signInPage) Event(gtx *layout.Context) interface{} {
if p.submit.Clicked(gtx) {
for _, f := range p.fields {
*f.Value = f.edit.Text()
}
return SignInEvent{p.account}
}
return nil
}
func (p *signInPage) Layout(gtx *layout.Context) {
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func() {
var t Topbar
t.Layout(gtx, p.env.insets, func() {
lbl := material.H6(theme, "Sign in")
lbl.Color = rgb(0xffffff)
lbl.Layout(gtx)
})
}),
layout.Flexed(1, func() {
p.layoutSigninForm(gtx)
}),
)
}
func (p *signInPage) layoutSigninForm(gtx *layout.Context) {
l := p.list
inset := layout.Inset{
Left: unit.Max(gtx, unit.Dp(32), p.env.insets.Left),
Right: unit.Max(gtx, unit.Dp(32), p.env.insets.Right),
}
l.Layout(gtx, len(p.fields)+1, func(i int) {
in := inset
switch {
case i < len(p.fields):
in.Bottom = unit.Dp(12)
if i == 0 {
in.Top = unit.Dp(32)
}
in.Layout(gtx, func() {
p.fields[i].Layout(gtx)
})
default:
in.Bottom = unit.Max(gtx, unit.Dp(32), p.env.insets.Bottom)
layout.E.Layout(gtx, func() {
in.Layout(gtx, func() {
material.Button(theme, "Sign in").Layout(gtx, p.submit)
})
})
}
})
}
func (f *formField) Layout(gtx *layout.Context) {
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func() {
gtx.Constraints.Width.Min = gtx.Constraints.Width.Max
header := material.Caption(theme, f.Header)
header.Font.Weight = text.Bold
header.Layout(gtx)
gtx.Dimensions.Size.Y += gtx.Px(unit.Dp(4))
}),
layout.Rigid(func() {
material.Editor(theme, f.Hint).Layout(gtx, f.edit)
}),
)
}
type Background struct {
Color color.RGBA
Radius unit.Value
Inset layout.Inset
}
func (b *Background) Layout(gtx *layout.Context, w layout.Widget) {
var macro op.MacroOp
macro.Record(gtx.Ops)
b.Inset.Layout(gtx, w)
macro.Stop()
var stack op.StackOp
stack.Push(gtx.Ops)
size := gtx.Dimensions.Size
width, height := float32(size.X), float32(size.Y)
if r := float32(gtx.Px(b.Radius)); r > 0 {
if r > width/2 {
r = width / 2
}
if r > height/2 {
r = height / 2
}
clip.Rect{
Rect: f32.Rectangle{Max: f32.Point{
X: width, Y: height,
}}, NW: r, NE: r, SW: r, SE: r,
}.Op(gtx.Ops).Add(gtx.Ops)
}
paint.ColorOp{Color: b.Color}.Add(gtx.Ops)
paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X: width, Y: height}}}.Add(gtx.Ops)
macro.Add()
stack.Pop()
}
func newThreadsPage(env *Env) *threadsPage {
return &threadsPage{
env: env,
list: &layout.List{
Axis: layout.Vertical,
},
fab: new(widget.Button),
}
}
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(gtx *layout.Context) 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:
}
if p.fab.Clicked(gtx) {
return ShowContactsEvent{}
}
for i := range p.clicks {
click := &p.clicks[i]
for _, e := range click.Events(gtx) {
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(gtx *layout.Context) {
layout.Stack{Alignment: layout.SE}.Layout(gtx,
layout.Stacked(func() {
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func() {
var t Topbar
t.Layout(gtx, p.env.insets, func() {
lbl := material.H6(theme, p.account.User)
lbl.Color = rgb(0xffffff)
lbl.Layout(gtx)
})
}),
layout.Flexed(1, func() {
p.layoutThreads(gtx)
}),
)
}),
layout.Stacked(func() {
layout.SE.Layout(gtx, func() {
layout.Inset{
Right: unit.Max(gtx, unit.Dp(16), p.env.insets.Right),
Bottom: unit.Max(gtx, unit.Dp(16), p.env.insets.Bottom),
}.Layout(gtx, func() {
material.IconButton(theme, iconLib.create).Layout(gtx, p.fab)
})
})
}),
)
}
func (p *threadsPage) layoutThreads(gtx *layout.Context) {
l := p.list
if l.Dragging() {
key.HideInputOp{}.Add(gtx.Ops)
}
l.Layout(gtx, len(p.threads), func(i int) {
in := layout.Inset{}
switch i {
case 0:
in.Top = unit.Dp(4)
case len(p.threads) - 1:
in.Bottom = unit.Max(gtx, unit.Dp(4), p.env.insets.Bottom)
}
in.Layout(gtx, func() {
p.thread(gtx, i)
})
})
}
var contactColors = []color.RGBA{
{A: 0xff, R: 0xef, G: 0x6c, B: 0x00},
{A: 0xff, R: 0x00, G: 0x57, B: 0x9b},
{A: 0xff, R: 0x00, G: 0x97, B: 0xa7},
{A: 0xff, R: 0x00, G: 0x4d, B: 0x40},
{A: 0xff, R: 0x7b, G: 0x1f, B: 0xa2},
{A: 0xff, R: 0x00, G: 0x89, B: 0x7b},
}
func (p *threadsPage) thread(gtx *layout.Context, index int) {
t := p.threads[index]
bgtexcol := rgb(0xbbbbbb)
fontWeight := text.Normal
if t.Unread > 0 {
bgtexcol = theme.Color.Text
fontWeight = text.Bold
}
click := &p.clicks[index]
in := layout.Inset{
Left: unit.Max(gtx, unit.Dp(16), p.env.insets.Left),
Right: unit.Max(gtx, unit.Dp(16), p.env.insets.Right),
}
in.Layout(gtx, func() {
in := layout.Inset{Top: unit.Dp(8), Bottom: unit.Dp(8)}
in.Layout(gtx, func() {
centerRowOpts().Layout(gtx,
layout.Rigid(func() {
in := layout.Inset{Right: unit.Dp(12)}
cc := clipCircle{}
in.Layout(gtx, func() {
cc.Layout(gtx, func() {
layout.Stack{Alignment: layout.Center}.Layout(gtx,
// Background color
layout.Stacked(func() {
sz := image.Point{X: gtx.Px(unit.Dp(48)), Y: gtx.Px(unit.Dp(48))}
gtx.Constraints = layout.RigidConstraints(gtx.Constraints.Constrain(sz))
color := contactColors[index%len(contactColors)]
fill{color}.Layout(gtx)
}),
// Contact initial.
layout.Stacked(func() {
initial := ""
for _, c := range t.ID {
initial = string(unicode.ToUpper(c))
break
}
lbl := material.H5(theme, initial)
lbl.Color = rgb(0xffffff)
lbl.Layout(gtx)
}),
)
})
})
}),
layout.Rigid(func() {
column().Layout(gtx,
layout.Rigid(func() {
baseline().Layout(gtx,
layout.Rigid(func() {
lbl := material.H6(theme, t.ID)
lbl.Font.Weight = fontWeight
lbl.Layout(gtx)
}),
layout.Flexed(1, func() {
gtx.Constraints.Width.Min = gtx.Constraints.Width.Max
in := layout.Inset{Left: unit.Dp(2)}
in.Layout(gtx, func() {
lbl := material.Caption(theme, formatTime(t.Updated))
lbl.Color = bgtexcol
lbl.Alignment = text.End
lbl.Font.Weight = fontWeight
lbl.Layout(gtx)
})
}),
)
}),
layout.Rigid(func() {
in := layout.Inset{Top: unit.Dp(6)}
in.Layout(gtx, func() {
lbl := material.Body2(theme, t.Snippet)
lbl.Color = bgtexcol
lbl.Font.Weight = fontWeight
lbl.MaxLines = 1
lbl.Layout(gtx)
})
}),
)
}),
)
})
pointer.Rect(image.Rectangle{Max: gtx.Dimensions.Size}).Add(gtx.Ops)
click.Add(gtx.Ops)
})
}
func newThreadPage(env *Env, threadID string) *threadPage {
thread := env.client.Thread(threadID)
return &threadPage{
env: env,
thread: thread,
checkmark: &icon{src: icons.ActionDone, size: unit.Dp(12)},
list: &layout.List{
Axis: layout.Vertical,
ScrollToEnd: true,
},
result: make(chan []*Message, 1),
msgEdit: &widget.Editor{
Submit: true,
},
send: new(widget.Button),
invite: new(widget.Button),
accept: new(widget.Button),
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(gtx *layout.Context) interface{} {
select {
case <-p.updates:
p.fetchMessages()
default:
}
for _, e := range p.msgEdit.Events(gtx) {
if _, ok := e.(widget.SubmitEvent); ok {
p.sendMessage()
}
}
if p.send.Clicked(gtx) {
p.sendMessage()
}
if p.invite.Clicked(gtx) {
if err := p.env.client.Send(p.thread.ID, "Invitation sent"); err != nil {
log.Printf("failed to send invitation: %v", err)
}
}
if p.accept.Clicked(gtx) {
if err := p.env.client.Send(p.thread.ID, "Invitation accepted"); err != nil {
log.Printf("failed to send invitation accept: %v", err)
}
}
return p.topbar.Event(gtx)
}
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(gtx *layout.Context) {
l := p.list
if l.Dragging() {
key.HideInputOp{}.Add(gtx.Ops)
}
select {
case p.messages = <-p.result:
default:
}
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func() {
p.topbar.Layout(gtx, p.env.insets, func() {
lbl := material.H6(theme, p.thread.ID)
lbl.Color = rgb(0xffffff)
lbl.Layout(gtx)
})
}),
layout.Flexed(1, func() {
gtx.Constraints.Height.Min = gtx.Constraints.Height.Max
l.Layout(gtx, len(p.messages), func(i int) {
p.message(gtx, i)
})
}),
layout.Rigid(func() {
in := layout.Inset{
Top: unit.Dp(16),
Left: unit.Max(gtx, unit.Dp(16), p.env.insets.Left),
Right: unit.Max(gtx, unit.Dp(16), p.env.insets.Right),
Bottom: unit.Max(gtx, unit.Dp(16), p.env.insets.Bottom),
}
in.Layout(gtx, func() {
switch {
case p.thread.PendingInvitation:
material.Button(theme, "Accept invitation").Layout(gtx, p.accept)
case p.env.client.ContainsSession(p.thread.ID):
p.layoutMessageBox(gtx)
default:
material.Button(theme, "Send invitation").Layout(gtx, p.invite)
}
})
}),
)
}
func (p *threadPage) layoutMessageBox(gtx *layout.Context) {
if mh := gtx.Px(unit.Dp(100)); gtx.Constraints.Height.Max > mh {
gtx.Constraints.Height.Max = mh
}
var sendHeight int
layout.Flex{Alignment: layout.End}.Layout(gtx,
layout.Flexed(1, func() {
gtx.Constraints.Width.Min = gtx.Constraints.Width.Max
if gtx.Constraints.Height.Min < sendHeight {
gtx.Constraints.Height.Min = sendHeight
}
bg := Background{
Color: rgb(0xeeeeee),
Inset: layout.Inset{Left: unit.Dp(8), Right: unit.Dp(8)},
Radius: unit.Dp(10),
}
bg.Layout(gtx, func() {
layout.W.Layout(gtx, func() {
gtx.Constraints.Width.Min = gtx.Constraints.Width.Max
ed := material.Editor(theme, "Send a message")
ed.TextSize = unit.Sp(14)
ed.Layout(gtx, p.msgEdit)
})
})
}),
layout.Rigid(func() {
in := layout.Inset{Left: unit.Dp(8)}
in.Layout(gtx, func() {
btn := material.IconButton(theme, iconLib.send)
btn.Size = unit.Dp(48)
btn.Padding = unit.Dp(12)
btn.Layout(gtx, p.send)
sendHeight = gtx.Dimensions.Size.Y
})
}),
)
}
func (p *threadPage) message(gtx *layout.Context, index int) {
msg := p.messages[index]
in := layout.Inset{Top: unit.Dp(16), Left: unit.Dp(16), Right: unit.Dp(40)}
align := layout.W
msgCol := rgb(0xffffff)
bgcol := theme.Color.Primary
timecol := argb(0xaaaaaaaa)
if msg.Own {
in.Left, in.Right = in.Right, in.Left
align = layout.E
bgcol = rgb(0xeeeeee)
msgCol = theme.Color.Text
timecol = rgb(0x888888)
}
in.Left = unit.Max(gtx, in.Left, p.env.insets.Left)
in.Right = unit.Max(gtx, in.Right, p.env.insets.Right)
in.Layout(gtx, func() {
align.Layout(gtx, func() {
bg := Background{
Color: bgcol,
Inset: layout.Inset{Top: unit.Dp(8), Bottom: unit.Dp(8), Left: unit.Dp(12), Right: unit.Dp(12)},
Radius: unit.Dp(10),
}
bg.Layout(gtx, func() {
var msgWidth int
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func() {
lbl := material.Body2(theme, msg.Message)
lbl.Color = msgCol
lbl.Layout(gtx)
gtx.Dimensions.Size.Y += gtx.Px(unit.Dp(4))
msgWidth = gtx.Dimensions.Size.X
}),
layout.Rigid(func() {
gtx.Constraints.Width.Min = msgWidth
f := layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween, Alignment: layout.Middle}
var children []layout.FlexChild
child := layout.Rigid(func() {
time := formatTime(msg.Time)
lbl := material.Caption(theme, time)
lbl.Color = timecol
lbl.Layout(gtx)
})
children = append(children, child)
if msg.Own {
child := layout.Rigid(func() {
in := layout.Inset{Left: unit.Dp(12)}
in.Layout(gtx, func() {
checkmark := p.checkmark.image(gtx, timecol)
sz := checkmark.Size()
if msg.Sent {
checkmark.Add(gtx.Ops)
paint.PaintOp{Rect: f32.Rectangle{Max: toPointF(sz)}}.Add(gtx.Ops)
}
gtx.Dimensions = layout.Dimensions{Size: sz}
})
})
children = append(children, child)
}
f.Layout(gtx, children...)
}),
)
})
})
})
}
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 (a *App) update(gtx *layout.Context) {
page := a.stack.Current()
if e := page.Event(gtx); 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.Swap(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 {
color color.RGBA
}
func (f fill) Layout(gtx *layout.Context) {
cs := gtx.Constraints
d := image.Point{X: cs.Width.Min, Y: cs.Height.Min}
dr := f32.Rectangle{
Max: f32.Point{X: float32(d.X), Y: float32(d.Y)},
}
paint.ColorOp{Color: f.color}.Add(gtx.Ops)
paint.PaintOp{Rect: dr}.Add(gtx.Ops)
gtx.Dimensions = layout.Dimensions{Size: d, Baseline: d.Y}
}
func column() layout.Flex {
return layout.Flex{Axis: layout.Vertical}
}
func centerRowOpts() layout.Flex {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}
}
func baseline() layout.Flex {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Baseline}
}
type clipCircle struct {
}
func (cc *clipCircle) Layout(gtx *layout.Context, w layout.Widget) {
var macro op.MacroOp
macro.Record(gtx.Ops)
w()
dims := gtx.Dimensions
macro.Stop()
max := dims.Size.X
if dy := dims.Size.Y; dy > max {
max = dy
}
szf := float32(max)
rr := szf * .5
var stack op.StackOp
stack.Push(gtx.Ops)
clip.Rect{
Rect: f32.Rectangle{Max: f32.Point{X: szf, Y: szf}},
NE: rr, NW: rr, SE: rr, SW: rr,
}.Op(gtx.Ops).Add(gtx.Ops)
macro.Add()
stack.Pop()
}
func toPointF(p image.Point) f32.Point {
return f32.Point{X: float32(p.X), Y: float32(p.Y)}
}
func (ic *icon) image(c unit.Converter, col color.RGBA) paint.ImageOp {
sz := c.Px(ic.size)
if sz == ic.imgSize {
return ic.op
}
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.op = paint.NewImageOp(img)
ic.imgSize = sz
return ic.op
}