package ui import ( "context" "fmt" "sync" "time" "gioui.org/layout" "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "git.wxccs.org/iceking2nd/winauth-go/internal/authenticator" "git.wxccs.org/iceking2nd/winauth-go/internal/global" "git.wxccs.org/iceking2nd/winauth-go/internal/i18n" ) // steamTradeRow holds per-row UI state for a single pending confirmation // so accept/reject button clicks survive across frames. type steamTradeRow struct { conf authenticator.Confirmation acceptBn widget.Clickable rejectBn widget.Clickable busy bool // an op is in flight for this id status string // last per-row status message } // steamTradesDialog lists a Steam authenticator's pending trade / // market confirmations and lets the user accept or reject each one. // // SECURITY: holds a live SteamClient with OAuth cookies. Closing the // dialog drops the reference but does NOT log the user out — the same // session will be reused next time the dialog is opened. type steamTradesDialog struct { client *authenticator.SteamClient authImpl *authenticator.SteamAuthenticator authName string closeBtn widget.Clickable refreshBn widget.Clickable list widget.List mu sync.Mutex loading bool loadErr error rows []*steamTradeRow loadedAt time.Time invalidate func() onClose func() } // newSteamTradesDialog constructs the dialog around an authenticator // instance, reusing its persisted SessionData to skip a fresh login. // Returns nil if the authenticator has no usable session. func newSteamTradesDialog( auth *authenticator.SteamAuthenticator, name string, invalidate, onClose func(), ) (*steamTradesDialog, error) { const fn = "internal.ui.newSteamTradesDialog" client, err := authenticator.NewSteamClient(auth, auth.SessionData) if err != nil { global.Log.WithField("func", fn).WithError(err).Warn("steam client init failed") return nil, err } d := &steamTradesDialog{ client: client, authImpl: auth, authName: name, invalidate: invalidate, onClose: onClose, } d.list.Axis = layout.Vertical // Kick off the first load right away. d.refresh() return d, nil } // Layout draws the modal. It does not implement the Dialog interface // (no Add-style onDone signature) — the parent appState owns the close // callback directly. func (d *steamTradesDialog) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { if d.closeBtn.Clicked(gtx) && d.onClose != nil { d.onClose() return layout.Dimensions{Size: gtx.Constraints.Max} } if d.refreshBn.Clicked(gtx) { d.refresh() } // Handle per-row accept/reject clicks. d.mu.Lock() rowsSnapshot := d.rows d.mu.Unlock() for _, r := range rowsSnapshot { if r.busy { continue } if r.acceptBn.Clicked(gtx) { d.runConfirm(r, true) } else if r.rejectBn.Clicked(gtx) { d.runConfirm(r, false) } } body := func(gtx layout.Context) layout.Dimensions { d.mu.Lock() loading := d.loading loadErr := d.loadErr rows := d.rows d.mu.Unlock() if loadErr != nil { return errorLabel(th, fmt.Sprintf(i18n.T("steam_trades_error"), loadErr.Error()))(gtx) } if loading && len(rows) == 0 { return material.Body2(th, i18n.T("steam_trades_loading")).Layout(gtx) } if len(rows) == 0 { return material.Body2(th, i18n.T("steam_trades_empty")).Layout(gtx) } // Cap list height so the modal does not exceed the window. gtx.Constraints.Max.Y = gtx.Dp(360) return material.List(th, &d.list).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { return d.layoutRow(gtx, th, rows[i]) }) } return modalCard(gtx, th, i18n.T("dialog_steam_trades_title"), i18n.T("btn_refresh"), i18n.T("btn_close"), &d.refreshBn, &d.closeBtn, body) } // layoutRow renders one pending confirmation. The image URL is shown // rather than fetched: the trade list can be long and async image // loading would add complexity not present in the original WinAuth UI. func (d *steamTradesDialog) layoutRow( gtx layout.Context, th *material.Theme, r *steamTradeRow, ) layout.Dimensions { return layout.Inset{Top: unit.Dp(4), Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Body1(th, r.conf.Details).Layout), layout.Rigid(material.Body2(th, r.conf.Traded).Layout), layout.Rigid(material.Caption(th, r.conf.When).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { if r.status == "" { return layout.Dimensions{} } return material.Body2(th, r.status).Layout(gtx) }), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return layout.Dimensions{Size: gtx.Constraints.Min} }), layout.Rigid(material.Button(th, &r.rejectBn, i18n.T("btn_reject")).Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Inset{Left: unit.Dp(8)}.Layout(gtx, material.Button(th, &r.acceptBn, i18n.T("btn_accept")).Layout) }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(thinDivider(th)), ) }) } // refresh kicks off (or restarts) a background GetConfirmations call. func (d *steamTradesDialog) refresh() { const fn = "internal.ui.steamTradesDialog.refresh" d.mu.Lock() if d.loading { d.mu.Unlock() return } d.loading = true d.loadErr = nil d.mu.Unlock() go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() confs, err := d.client.GetConfirmations(ctx) d.mu.Lock() d.loading = false d.loadedAt = time.Now() if err != nil { d.loadErr = err global.Log.WithField("func", fn).WithError(err).Warn("get confirmations failed") } else { d.rows = mergeConfirmations(d.rows, confs) } d.mu.Unlock() // Refresh persists rotated session cookies; push them to disk. d.syncSessionToAuthenticator() if d.invalidate != nil { d.invalidate() } }() } // runConfirm fires the accept/reject HTTP call for a single trade. func (d *steamTradesDialog) runConfirm(r *steamTradeRow, accept bool) { const fn = "internal.ui.steamTradesDialog.runConfirm" d.mu.Lock() r.busy = true r.status = i18n.T("msg_busy") d.mu.Unlock() go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() ok, err := d.client.ConfirmTrade(ctx, r.conf.Id, r.conf.Key, accept) d.mu.Lock() r.busy = false switch { case err != nil: r.status = fmt.Sprintf(i18n.T("steam_trades_error"), err.Error()) global.Log.WithField("func", fn).WithError(err).Warn("confirm trade failed") case !ok: r.status = i18n.T("steam_trades_error") default: // Drop the row from the visible list — Steam considers it done. out := d.rows[:0] for _, x := range d.rows { if x.conf.Id != r.conf.Id { out = append(out, x) } } d.rows = out } d.mu.Unlock() d.syncSessionToAuthenticator() if d.invalidate != nil { d.invalidate() } }() } // syncSessionToAuthenticator copies the latest Session JSON from the // client back onto the authenticator's SessionData so the next save // persists rotated cookies. Called after any network round-trip. func (d *steamTradesDialog) syncSessionToAuthenticator() { if d.client == nil || d.authImpl == nil || d.client.Session == nil { return } d.authImpl.SessionData = d.client.Session.ToJSON() } // mergeConfirmations rebuilds the row slice from a fresh confirmations // list, preserving the per-row Clickable state for ids that survived. func mergeConfirmations( old []*steamTradeRow, fresh []authenticator.Confirmation, ) []*steamTradeRow { byID := make(map[string]*steamTradeRow, len(old)) for _, r := range old { byID[r.conf.Id] = r } out := make([]*steamTradeRow, 0, len(fresh)) for _, c := range fresh { if existing, ok := byID[c.Id]; ok { existing.conf = c out = append(out, existing) } else { out = append(out, &steamTradeRow{conf: c}) } } return out } // thinDivider returns a 1dp horizontal rule used between rows. func thinDivider(_ *material.Theme) layout.Widget { return func(gtx layout.Context) layout.Dimensions { // material.Divider doesn't exist in this Gio version; draw a // thin rectangle instead. gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(1)) gtx.Constraints.Max.Y = gtx.Constraints.Min.Y return layout.Dimensions{Size: gtx.Constraints.Min} } }