Skip to content

Commit

Permalink
clients: support async page fetching
Browse files Browse the repository at this point in the history
An async fetch replaces a section of the page with content without full
page reload. Example syntax:

--form--
type="action" value="/remote/resource"
type="submit" label="Submit Invoice"
type="asynctarget" value="section_one"
--/form--

--section id=section_one --
--/section--

When the form is submitted, brclient/bruig fetches the contents of
/remote/resource, then replaces everything inside #section_one with it.

Sections and forms should not be nested (that is unsupported).
  • Loading branch information
miki committed Jul 29, 2024
1 parent 6a09e89 commit f8baaaf
Show file tree
Hide file tree
Showing 23 changed files with 772 additions and 116 deletions.
34 changes: 26 additions & 8 deletions brclient/appstate.go
Original file line number Diff line number Diff line change
Expand Up @@ -1281,9 +1281,8 @@ func (as *appState) findPagesChatWindow(sessID clientintf.PagesSessionID) *chatW
return nil
}

func (as *appState) findOrNewPagesChatWindow(sessID clientintf.PagesSessionID) *chatWindow {
func (as *appState) findOrNewPagesChatWindow(sessID clientintf.PagesSessionID) (cw *chatWindow, isNew bool) {
as.chatWindowsMtx.Lock()
var cw *chatWindow
for i, acw := range as.chatWindows {
if acw.isPage && acw.pageSess == sessID {
if i != as.activeCW {
Expand All @@ -1303,10 +1302,11 @@ func (as *appState) findOrNewPagesChatWindow(sessID clientintf.PagesSessionID) *
}
as.chatWindows = append(as.chatWindows, cw)
as.updatedCW[len(as.chatWindows)-1] = false
isNew = true
}
as.chatWindowsMtx.Unlock()
as.footerInvalidate()
return cw
return
}

// markWindowUpdated marks the window as updated.
Expand Down Expand Up @@ -2447,7 +2447,7 @@ func (as *appState) downloadEmbed(source clientintf.UserID, embedded mdembeds.Em

// fetchPage requests the given page from the user.
func (as *appState) fetchPage(uid clientintf.UserID, pagePath string, session,
parent clientintf.PagesSessionID, form *formEl) error {
parent clientintf.PagesSessionID, form *formEl, asyncTargetId string) error {
if len(pagePath) < 1 {
return fmt.Errorf("page path is empty")
}
Expand Down Expand Up @@ -2498,14 +2498,15 @@ func (as *appState) fetchPage(uid clientintf.UserID, pagePath string, session,
return err
}

tag, err := as.c.FetchResource(uid, path, nil, session, parent, data)
tag, err := as.c.FetchResource(uid, path, nil, session, parent, data,
asyncTargetId)
if err != nil {
return err
}

// Mark session making a new request (if it already exists).
cw := as.findPagesChatWindow(session)
if cw != nil {
if cw != nil && asyncTargetId == "" {
cw.Lock()
cw.pageRequested = &path
cw.Unlock()
Expand All @@ -2514,6 +2515,9 @@ func (as *appState) fetchPage(uid clientintf.UserID, pagePath string, session,
// Initialize the page spinner.
as.sendMsg(msgActiveCWRequestedPage{[]tea.Cmd{cw.pageSpinner.Tick}})
}
} else if cw != nil && asyncTargetId != "" {
cw.replaceAsyncTargetWithLoading(asyncTargetId)
as.repaintIfActive(cw)
}

as.diagMsg("Attempting to fetch %s from %s (session %s, tag %s)",
Expand Down Expand Up @@ -3183,8 +3187,22 @@ func newAppState(sendMsg func(tea.Msg), lndLogLines *sloglinesbuffer.Buffer,
return
}

cw := as.findOrNewPagesChatWindow(fr.SessionID)
cw.replacePage(nick, fr)
cw, isNew := as.findOrNewPagesChatWindow(fr.SessionID)

// When this is the response to an async request and this is the
// first time this page is opened, load the entire history of
// the page and its async requests.
var history []*clientdb.FetchedResource
if isNew && fr.AsyncTargetID != "" {
history, err = as.c.LoadFetchedResource(uid, fr.SessionID, fr.ParentPage)
if err != nil {
as.diagMsg("Error loading history for page %s/%s: %v",
fr.SessionID, fr.ParentPage, err)
return
}
}

cw.replacePage(nick, fr, history)
sendMsg(msgPageFetched{
uid: uid,
nick: nick,
Expand Down
139 changes: 135 additions & 4 deletions brclient/chatwindow.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,20 @@ type formEl struct {
fields []*formField
}

// asyncTarget returns the id of the target when the form has an asynctarget
// field.
func (f *formEl) asyncTarget() string {
for _, ff := range f.fields {
if ff.typ == "asynctarget" && ff.value != nil {
if target, ok := ff.value.(string); ok {
return target
}
}
}

return ""
}

func (f *formEl) action() string {
for _, ff := range f.fields {
if ff.typ == "action" && ff.value != nil {
Expand Down Expand Up @@ -407,6 +421,11 @@ func parseMsgLine(line string, mention string) *chatMsgElLine {
return res
}

var (
sectionStartRegexp = regexp.MustCompile(`--section id=([\w]+) --`)
sectionEndRegexp = regexp.MustCompile(`--/section--`)
)

func parseMsgIntoElements(msg string, mention string) []*chatMsgElLine {
// First, break into lines.
lines := strings.Split(msg, "\n")
Expand All @@ -418,6 +437,10 @@ func parseMsgIntoElements(msg string, mention string) []*chatMsgElLine {
form = &formEl{}
case line == "--/form--":
form = nil
case sectionStartRegexp.MatchString(line):
// Skip section start line
case sectionEndRegexp.MatchString(line):
// Skip section end line
case form != nil:
ff := parseFormField(line)
if ff != nil {
Expand All @@ -429,6 +452,7 @@ func parseMsgIntoElements(msg string, mention string) []*chatMsgElLine {
el.PushBack(msgEl)
res = append(res, el)
}

default:
res = append(res, parseMsgLine(line, mention))
}
Expand Down Expand Up @@ -564,7 +588,54 @@ func (cw *chatWindow) newRecvdMsg(from, msg string, fromUID *zkidentity.ShortID,
return m
}

func (cw *chatWindow) replacePage(nick string, fr clientdb.FetchedResource) {
func (cw *chatWindow) replaceAsyncTargetWithLoading(asyncTargetID string) {
cw.Lock()
defer cw.Unlock()

if len(cw.msgs) == 0 {
return
}
if cw.page == nil {
return
}

data := cw.page.Response.Data

reStartPattern := `--section id=` + asyncTargetID + ` --\n`
reStart, err := regexp.Compile(reStartPattern)
if err != nil {
// Skip invalid ids.
return
}

startPos := reStart.FindIndex(data)
if startPos == nil {
// Did not find the target location.
return
}

endPos := sectionEndRegexp.FindIndex(data[startPos[1]:])
if endPos == nil {
// Unterminated section.
return
}
endPos[0] += startPos[1] // Convert to absolute index

// Copy the rest of the string to an aux buffer.
aux := append([]byte(nil), data[endPos[0]:]...)

// Create the new buffer, replacing the contents inside
// the section with this response.
data = data[0:startPos[1]]
data = append(data, []byte("(⏳ Loading response)\n")...)
data = append(data, aux...)

msg := cw.msgs[0]
msg.elements = parseMsgIntoElements(string(data), "")
cw.page.Response.Data = data
}

func (cw *chatWindow) replacePage(nick string, fr clientdb.FetchedResource, history []*clientdb.FetchedResource) {
cw.Lock()
var msg *chatMsg
if len(cw.msgs) == 0 {
Expand All @@ -573,10 +644,70 @@ func (cw *chatWindow) replacePage(nick string, fr clientdb.FetchedResource) {
} else {
msg = cw.msgs[0]
}
msg.elements = parseMsgIntoElements(string(fr.Response.Data), "")

// Replace async targets.
var data, aux []byte
if len(history) > 0 || fr.AsyncTargetID != "" {
// If there is history, this is loading from disk, so use only
// whats in the slice. Otherwise, replace the response data.
var toProcess []*clientdb.FetchedResource
if len(history) > 0 {
data = history[0].Response.Data
toProcess = history[1:]
} else {
data = cw.page.Response.Data
toProcess = []*clientdb.FetchedResource{&fr}
}

// Process the async targets.
for _, asyncRes := range toProcess {
reStartPattern := `--section id=` + asyncRes.AsyncTargetID + ` --\n`
reStart, err := regexp.Compile(reStartPattern)
if err != nil {
// Skip invalid ids.
continue
}

startPos := reStart.FindIndex(data)
if startPos == nil {
// Did not find the target location.
continue
}

endPos := sectionEndRegexp.FindIndex(data[startPos[1]:])
if endPos == nil {
// Unterminated section.
continue
}
endPos[0] += startPos[1] // Convert to absolute index

// Copy the rest of the string to an aux buffer.
aux = append(aux, data[endPos[0]:]...)

// Create the new buffer, replacing the contents inside
// the section with this response.
data = data[0:startPos[1]]
data = append(data, asyncRes.Response.Data...)
data = append(data, aux...)
aux = aux[:0]
}
} else {
data = fr.Response.Data
}

msg.elements = parseMsgIntoElements(string(data), "")
msg.fromUID = &fr.UID
cw.page = &fr
cw.selElIndex = 0
if len(history) > 0 {
cw.page = history[0]
} else if fr.AsyncTargetID == "" {
cw.page = &fr
}
cw.page.Response.Data = data
if history != nil || fr.AsyncTargetID == "" {
// Only reset the selected element index when replacing the
// entire page.
cw.selElIndex = 0
}
cw.pageRequested = nil
cw.alias = fmt.Sprintf("%v/%v", nick, strings.Join(fr.Request.Path, "/"))
cw.Unlock()
Expand Down
4 changes: 2 additions & 2 deletions brclient/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -3113,7 +3113,7 @@ var pagesCommands = []tuicmd{
if len(args) > 1 {
pagePath = strings.TrimSpace(args[1])
}
return as.fetchPage(uid, pagePath, nextSess, 0, nil)
return as.fetchPage(uid, pagePath, nextSess, 0, nil, "")
},
completer: func(args []string, arg string, as *appState) []string {
if len(args) == 0 {
Expand All @@ -3135,7 +3135,7 @@ var pagesCommands = []tuicmd{
// Always use the same session ID for convenience when
// fetching a local page.
nextSess := clientintf.PagesSessionID(0)
return as.fetchPage(as.c.PublicID(), pagePath, nextSess, 0, nil)
return as.fetchPage(as.c.PublicID(), pagePath, nextSess, 0, nil, "")
},
},
}
Expand Down
10 changes: 6 additions & 4 deletions brclient/mainwindow.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ func (mws mainWindowState) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Navigate to other page.
uid := cw.page.UID
err := mws.as.fetchPage(uid, *cw.selEl.link,
cw.page.SessionID, cw.page.PageID, nil)
cw.page.SessionID, cw.page.PageID, nil, "")
if err != nil {
mws.as.diagMsg("Unable to fetch page: %v", err)
}
Expand All @@ -316,7 +316,8 @@ func (mws mainWindowState) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
action := cw.selEl.form.action()

err := mws.as.fetchPage(uid, action,
cw.page.SessionID, cw.page.PageID, cw.selEl.form)
cw.page.SessionID, cw.page.PageID,
cw.selEl.form, cw.selEl.form.asyncTarget())
if err != nil {
mws.as.diagMsg("Unable to fetch page: %v", err)
}
Expand Down Expand Up @@ -509,7 +510,7 @@ func (mws mainWindowState) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Navigate to other page.
uid := cw.page.UID
err := mws.as.fetchPage(uid, *cw.selEl.link,
cw.page.SessionID, cw.page.PageID, nil)
cw.page.SessionID, cw.page.PageID, nil, "")
if err != nil {
mws.as.diagMsg("Unable to fetch page: %v", err)
}
Expand All @@ -525,7 +526,8 @@ func (mws mainWindowState) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// }

err := mws.as.fetchPage(uid, action,
cw.page.SessionID, cw.page.PageID, cw.selEl.form)
cw.page.SessionID, cw.page.PageID, cw.selEl.form,
cw.selEl.form.asyncTarget())
if err != nil {
mws.as.diagMsg("Unable to fetch page: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion bruig/flutterui/bruig/lib/components/md_elements.dart
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ class MarkdownArea extends StatelessWidget {
var parentPageID = pageSource?.pageID ?? 0;
try {
await resources.fetchPage(
uid, parsed.pathSegments, sessionID, parentPageID, null);
uid, parsed.pathSegments, sessionID, parentPageID, null, "");
} catch (exception) {
snackbar.error("Unable to fetch page: $exception");
}
Expand Down
Loading

0 comments on commit f8baaaf

Please sign in to comment.