pico

created pr with pico/141.1 on 2026-09-26T01:18:41Z · by c8ef7d19
added pico/141.2 on 2026-09-26T01:40:03Z · by c8ef7d19
1: 2845ffb = 1: 2845ffb refactor: invite system for pgs and prose
-: ------- > 2: ee606d0 chore(prose): require plus or prose feature flag in order to upload
added pico/141.3 on 2026-09-26T14:41:34Z · by c8ef7d19
1: 2845ffb = 1: 2845ffb refactor: invite system for pgs and prose
2: ee606d0 = 2: ee606d0 chore(prose): require plus or prose feature flag in order to upload
-: ------- > 3: e14a99e refactor: remove unnecessary tests
added pico/141.4 on 2026-09-26T14:42:29Z · by c8ef7d19
1: 2845ffb = 1: 2845ffb refactor: invite system for pgs and prose
2: ee606d0 < -: ------- chore(prose): require plus or prose feature flag in order to upload
3: e14a99e ! 2: 7fb4f8f chore(prose): require plus or prose feature flag in order to upload
added pico/141.5 on 2026-09-26T15:11:30Z · by c8ef7d19
1: 2845ffb = 1: 2845ffb refactor: invite system for pgs and prose
2: 7fb4f8f ! 2: e469694 chore(prose): require plus or prose feature flag in order to upload
added pico/141.6 on 2026-10-01T13:39:16Z · by c8ef7d19
1: 2845ffb ! 1: 1e9cddc style(prose): minimal design ethos
2: e469694 ! 2: 0967882 chore(prose): remove with_styles and layout front-matter fields
cmds
checkout latest patchset:
ssh pr.pico.sh pull pico:141 | git am -3
checkout specific patchset revision:
ssh pr.pico.sh pull pico:141 [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pico:141
add comment to patch request:
ssh pr.pico.sh comment pico:141 "lgtm!"

Patchset pico/141.4 on 2026-09-26T14:42:29Z · commit 2845ffb

+2 -1 Makefile #
......@@ -159,10 +159,11 @@ migrate:
159159 $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260503_add_analytics_summary_tables.sql
160160 $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260504_add_analytics_summary_indexes.sql
161161 $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260716_block_signups.sql
162+ $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260925_add_invites.sql
162163 .PHONY: migrate
163164
164165 latest:
165- $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260716_block_signups.sql
166+ $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260925_add_invites.sql
166167 .PHONY: latest
167168
168169 psql:
+2 -0 pkg/db/db.go #
......@@ -643,5 +643,7 @@ type DB interface {
643643 InsertPipeMonitorHistory(monitorID string, windowDur time.Duration, windowEnd, lastPing *time.Time) error
644644 FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*PipeMonitorHistory, error)
645645
646+ InviteUser(fromUserID string, toUserID string) error
647+
646648 Close() error
647649 }
+18 -0 pkg/db/postgres/storage.go #
......@@ -2126,3 +2126,21 @@ func (me *PsqlDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) (
21262126 }
21272127 return history, nil
21282128 }
2129+
2130+func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error {
2131+ _, err := me.Db.Exec(
2132+ `INSERT INTO invites (from_user_id, to_user_id) VALUES ($1, $2)`,
2133+ fromUserID, toUserID,
2134+ )
2135+ if err != nil {
2136+ return err
2137+ }
2138+
2139+ expiresAt := time.Now().Add(5 * 365 * 24 * time.Hour)
2140+ _, err = me.InsertFeature(toUserID, "prose", expiresAt)
2141+ if err != nil {
2142+ return err
2143+ }
2144+ _, err = me.InsertFeature(toUserID, "pgs", expiresAt)
2145+ return err
2146+}
+4 -0 pkg/db/stub/stub.go #
......@@ -275,3 +275,7 @@ func (me *StubDB) InsertPipeMonitorHistory(monitorID string, windowDur time.Dura
275275 func (me *StubDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*db.PipeMonitorHistory, error) {
276276 return nil, errNotImpl
277277 }
278+
279+func (me *StubDB) InviteUser(fromUserID string, toUserID string) error {
280+ return errNotImpl
281+}
+13 -7 pkg/tui/info.go #
......@@ -153,12 +153,13 @@ func (m *FeaturesList) getFeaturesKv() []Kv {
153153 }
154154
155155 type ServicesList struct {
156- plusFf *db.FeatureFlag
157- pgsFf *db.FeatureFlag
156+ plusFf *db.FeatureFlag
157+ pgsFf *db.FeatureFlag
158+ proseFf *db.FeatureFlag
158159 }
159160
160-func NewServicesList(plusFf *db.FeatureFlag, pgsFf *db.FeatureFlag) *ServicesList {
161- return &ServicesList{plusFf: plusFf, pgsFf: pgsFf}
161+func NewServicesList(plusFf *db.FeatureFlag, pgsFf *db.FeatureFlag, proseFf *db.FeatureFlag) *ServicesList {
162+ return &ServicesList{plusFf: plusFf, pgsFf: pgsFf, proseFf: proseFf}
162163 }
163164
164165 func (m *ServicesList) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
......@@ -174,7 +175,7 @@ func (m *ServicesList) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
174175 return brd.Draw(vxfw.DrawContext{
175176 Characters: ctx.Characters,
176177 Max: vxfw.Size{
177- Width: 30,
178+ Width: 32,
178179 Height: uint16(servicesHeight) + 3,
179180 },
180181 })
......@@ -183,15 +184,20 @@ func (m *ServicesList) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
183184 func (m *ServicesList) getServiceKv() []Kv {
184185 hasPlus := m.plusFf != nil && m.plusFf.IsValid()
185186 hasPgs := m.pgsFf != nil && m.pgsFf.IsValid()
186- pagesStatus := "pico+"
187+ hasProse := m.proseFf != nil && m.proseFf.IsValid()
188+ pagesStatus := "invite/pico+"
187189 if hasPlus || hasPgs {
188190 pagesStatus = "active"
189191 }
192+ proseStatus := "invite/pico+"
193+ if hasPlus || hasProse {
194+ proseStatus = "active"
195+ }
190196 data := [][]string{
191197 {"name", "status"},
192- {"prose", "active"},
193198 {"pipe", "active"},
194199 {"pastes", "active"},
200+ {"prose", proseStatus},
195201 {"pages", pagesStatus},
196202 }
197203
+131 -0 pkg/tui/invite.go #
......@@ -0,0 +1,131 @@
1+package tui
2+
3+import (
4+ "fmt"
5+
6+ "git.sr.ht/~rockorager/vaxis"
7+ "git.sr.ht/~rockorager/vaxis/vxfw"
8+ "git.sr.ht/~rockorager/vaxis/vxfw/button"
9+ "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
10+ "git.sr.ht/~rockorager/vaxis/vxfw/text"
11+)
12+
13+type AddInvitePage struct {
14+ shared *SharedModel
15+
16+ err error
17+ focus string
18+ input *TextInput
19+ btn *button.Button
20+}
21+
22+func NewAddInvitePage(shrd *SharedModel) *AddInvitePage {
23+ btn := button.New("SEND", func() (vxfw.Command, error) { return nil, nil })
24+ btn.Style = button.StyleSet{
25+ Default: vaxis.Style{Background: grey},
26+ Focus: vaxis.Style{Background: oj, Foreground: black},
27+ }
28+ return &AddInvitePage{
29+ shared: shrd,
30+
31+ input: NewTextInput("pico user"),
32+ btn: btn,
33+ }
34+}
35+
36+func (m *AddInvitePage) Footer() []Shortcut {
37+ return []Shortcut{
38+ {Shortcut: "tab", Text: "focus"},
39+ {Shortcut: "enter", Text: "invite user"},
40+ }
41+}
42+
43+func (m *AddInvitePage) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
44+ switch msg := ev.(type) {
45+ case vaxis.Key:
46+ if msg.Matches(vaxis.KeyEnter) {
47+ err := m.addInvite(m.input.GetValue())
48+ m.err = err
49+ if err == nil {
50+ m.input.Reset()
51+ m.shared.App.PostEvent(Navigate{To: HOME})
52+ return nil, nil
53+ }
54+ return vxfw.RedrawCmd{}, nil
55+ }
56+ }
57+ return nil, nil
58+}
59+
60+func (m *AddInvitePage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
61+ switch msg := ev.(type) {
62+ case PageIn:
63+ m.err = nil
64+ m.focus = "input"
65+ m.input.Reset()
66+ return m.input.FocusIn()
67+ case vaxis.Key:
68+ if msg.Matches(vaxis.KeyTab) {
69+ if m.focus == "input" {
70+ m.focus = "button"
71+ cmd, _ := m.input.FocusOut()
72+ return vxfw.BatchCmd([]vxfw.Command{
73+ vxfw.FocusWidgetCmd(m.btn),
74+ cmd,
75+ }), nil
76+ }
77+ m.focus = "input"
78+ return m.input.FocusIn()
79+ }
80+ }
81+
82+ return nil, nil
83+}
84+
85+func (m *AddInvitePage) addInvite(username string) error {
86+ if m.shared.PlusFeatureFlag == nil {
87+ return fmt.Errorf("must be pico+ to invite users")
88+ }
89+ db := m.shared.Dbpool
90+ user, err := db.FindUserByName(username)
91+ if err != nil {
92+ return err
93+ }
94+
95+ return db.InviteUser(m.shared.User.ID, user.ID)
96+}
97+
98+func (m *AddInvitePage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
99+ w := ctx.Max.Width
100+ h := ctx.Max.Height
101+ root := vxfw.NewSurface(w, h, m)
102+ ah := 0
103+
104+ header := text.New("Invite a user to pico! They must already have a pico account. This grants them `pgs` and `prose` access on their respective free tiers. Only pico+ members can invite users.")
105+ headerSurf, _ := header.Draw(ctx)
106+ root.AddChild(0, ah, headerSurf)
107+ ah += int(headerSurf.Size.Height) + 1
108+
109+ inputSurf, _ := m.input.Draw(ctx)
110+ root.AddChild(0, ah, inputSurf)
111+ ah += int(headerSurf.Size.Height) + 1
112+
113+ btnSurf, _ := m.btn.Draw(vxfw.DrawContext{
114+ Characters: ctx.Characters,
115+ Max: vxfw.Size{Width: 6, Height: 1},
116+ })
117+ root.AddChild(0, ah, btnSurf)
118+
119+ if m.err != nil {
120+ e := richtext.New([]vaxis.Segment{
121+ {
122+ Text: m.err.Error(),
123+ Style: vaxis.Style{Foreground: red},
124+ },
125+ })
126+ errSurf, _ := e.Draw(createDrawCtx(ctx, 1))
127+ root.AddChild(0, ah, errSurf)
128+ }
129+
130+ return root, nil
131+}
+6 -1 pkg/tui/menu.go #
......@@ -10,6 +10,7 @@ import (
1010
1111 var menuChoices = []string{
1212 "pubkeys",
13+ "invite",
1314 "tokens",
1415 "logs",
1516 "access_logs",
......@@ -104,7 +105,11 @@ func (m *MenuPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
104105 },
105106 })
106107
107- services, _ := NewServicesList(m.shared.PlusFeatureFlag, m.shared.PgsFeatureFlag).Draw(ctx)
108+ services, _ := NewServicesList(
109+ m.shared.PlusFeatureFlag,
110+ m.shared.PgsFeatureFlag,
111+ m.shared.ProseFeatureFlag,
112+ ).Draw(ctx)
108113 features, _ := m.features.Draw(ctx)
109114
110115 leftPane := NewGroupStack([]vxfw.Surface{
+5 -0 pkg/tui/ui.go #
......@@ -31,6 +31,7 @@ type SharedModel struct {
3131 PlusFeatureFlag *db.FeatureFlag
3232 BouncerFeatureFlag *db.FeatureFlag
3333 PgsFeatureFlag *db.FeatureFlag
34+ ProseFeatureFlag *db.FeatureFlag
3435 Impersonator string
3536 App *vxfw.App
3637 }
......@@ -280,6 +281,9 @@ func initData(shrd *SharedModel) error {
280281
281282 pff, _ := FindFeatureFlag(shrd, "pgs")
282283 shrd.PgsFeatureFlag = pff
284+
285+ prosef, _ := FindFeatureFlag(shrd, "prose")
286+ shrd.ProseFeatureFlag = prosef
283287 return nil
284288 }
285289
......@@ -351,6 +355,7 @@ func NewTui(opts vaxis.Options, shrd *SharedModel) error {
351355 HOME: NewMenuPage(shrd),
352356 "pubkeys": NewPubkeysPage(shrd),
353357 "add-pubkey": NewAddPubkeyPage(shrd),
358+ "invite": NewAddInvitePage(shrd),
354359 "tokens": NewTokensPage(shrd),
355360 "add-token": NewAddTokenPage(shrd),
356361 "signup": NewSignupPage(shrd),
+16 -0 sql/migrations/20260925_add_invites.sql #
......@@ -0,0 +1,16 @@
1+CREATE TABLE IF NOT EXISTS invites (
2+ id SERIAL PRIMARY KEY,
3+ from_user_id uuid NOT NULL,
4+ to_user_id uuid NOT NULL,
5+ created_at timestamp without time zone NOT NULL DEFAULT NOW(),
6+ CONSTRAINT fk_invites_from_app_users
7+ FOREIGN KEY(from_user_id)
8+ REFERENCES app_users(id)
9+ ON DELETE CASCADE
10+ ON UPDATE CASCADE,
11+ CONSTRAINT fk_invites_to_app_users
12+ FOREIGN KEY(to_user_id)
13+ REFERENCES app_users(id)
14+ ON DELETE CASCADE
15+ ON UPDATE CASCADE
16+);
Back to top