diff --git a/brclient/appstate.go b/brclient/appstate.go index 69737e27..d9650c48 100644 --- a/brclient/appstate.go +++ b/brclient/appstate.go @@ -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 { @@ -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. @@ -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") } @@ -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() @@ -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)", @@ -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, diff --git a/brclient/chatwindow.go b/brclient/chatwindow.go index acdea98b..821a2097 100644 --- a/brclient/chatwindow.go +++ b/brclient/chatwindow.go @@ -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 { @@ -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") @@ -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 { @@ -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)) } @@ -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 { @@ -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() diff --git a/brclient/commands.go b/brclient/commands.go index b340c5df..e0ffdacc 100644 --- a/brclient/commands.go +++ b/brclient/commands.go @@ -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 { @@ -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, "") }, }, } diff --git a/brclient/mainwindow.go b/brclient/mainwindow.go index 516be6bd..8515385a 100644 --- a/brclient/mainwindow.go +++ b/brclient/mainwindow.go @@ -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) } @@ -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) } @@ -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) } @@ -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) } diff --git a/bruig/flutterui/bruig/lib/components/md_elements.dart b/bruig/flutterui/bruig/lib/components/md_elements.dart index e78309e5..ddc6153b 100644 --- a/bruig/flutterui/bruig/lib/components/md_elements.dart +++ b/bruig/flutterui/bruig/lib/components/md_elements.dart @@ -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"); } diff --git a/bruig/flutterui/bruig/lib/components/pages/forms.dart b/bruig/flutterui/bruig/lib/components/pages/forms.dart index 607427c4..753811bd 100644 --- a/bruig/flutterui/bruig/lib/components/pages/forms.dart +++ b/bruig/flutterui/bruig/lib/components/pages/forms.dart @@ -1,5 +1,4 @@ import 'package:bruig/components/empty_widget.dart'; -import 'package:bruig/components/info_grid.dart'; import 'package:bruig/components/md_elements.dart'; import 'package:bruig/components/inputs.dart'; import 'package:bruig/models/resources.dart'; @@ -8,12 +7,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:markdown/markdown.dart' as md; import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; import 'package:flutter/services.dart'; class _FormSubmitButton extends StatelessWidget { final FormElement form; - final _FormField submit; + final FormField submit; final GlobalKey formKey; const _FormSubmitButton(this.form, this.submit, this.formKey); @@ -21,10 +19,15 @@ class _FormSubmitButton extends StatelessWidget { var snackbar = SnackBarModel.of(context); Map formData = {}; String action = ""; + String asyncTargetID = ""; for (var field in form.fields) { if (field.type == "action") { action = field.value ?? ""; } + if (field.type == "asynctarget") { + asyncTargetID = field.value ?? ""; + continue; + } if (field.name == "" || field.value == null) { continue; } @@ -46,8 +49,8 @@ class _FormSubmitButton extends StatelessWidget { var parentPageID = pageSource?.pageID ?? 0; try { - await resources.fetchPage( - uid, parsed.pathSegments, sessionID, parentPageID, formData); + await resources.fetchPage(uid, parsed.pathSegments, sessionID, + parentPageID, formData, asyncTargetID); } catch (exception) { snackbar.error("Unable to fetch page: $exception"); } @@ -95,7 +98,7 @@ class CustomFormState extends State { FormElement get form => widget.form; @override Widget build(BuildContext context) { - _FormField? submit; + FormField? submit; List fieldWidgets = []; for (var field in form.fields) { @@ -165,6 +168,7 @@ class CustomFormState extends State { case "submit": submit = field; break; + case "asynctarget": case "hidden": case "action": break; @@ -190,7 +194,7 @@ class CustomFormState extends State { } } -class _FormField { +class FormField { final String type; final String name; final String label; @@ -199,7 +203,7 @@ class _FormField { final String regexpstr; final String hint; - _FormField(this.type, + FormField(this.type, {this.name = "", this.label = "", this.regexp = "", @@ -209,7 +213,7 @@ class _FormField { } class FormElement extends md.Element { - final List<_FormField> fields; + final List fields; FormElement(this.fields) : super("form", [md.Text("")]); } @@ -229,7 +233,7 @@ class FormBlockSyntax extends md.BlockSyntax { @override md.Node? parse(md.BlockParser parser) { parser.advance(); - List<_FormField> children = []; + List children = []; while (!parser.isDone && !md.BlockSyntax.isAtBlockEnd(parser)) { if (parser.current.content == closeTag) { @@ -260,7 +264,7 @@ class FormBlockSyntax extends md.BlockSyntax { } } - _FormField field = Function.apply(_FormField.new, [type], args); + FormField field = Function.apply(FormField.new, [type], args); children.add(field); parser.advance(); } diff --git a/bruig/flutterui/bruig/lib/models/feed.dart b/bruig/flutterui/bruig/lib/models/feed.dart index 9f6709b2..89aa25d5 100644 --- a/bruig/flutterui/bruig/lib/models/feed.dart +++ b/bruig/flutterui/bruig/lib/models/feed.dart @@ -4,7 +4,6 @@ import 'package:bruig/util.dart'; import 'package:collection/collection.dart'; import 'package:flutter/cupertino.dart'; import 'package:bruig/notification_service.dart'; -import 'package:bruig/screens/feed/post_content.dart'; import 'package:golib_plugin/definitions.dart'; import 'package:golib_plugin/golib_plugin.dart'; diff --git a/bruig/flutterui/bruig/lib/models/menus.dart b/bruig/flutterui/bruig/lib/models/menus.dart index 45365ffb..a3c49026 100644 --- a/bruig/flutterui/bruig/lib/models/menus.dart +++ b/bruig/flutterui/bruig/lib/models/menus.dart @@ -296,7 +296,7 @@ List buildUserChatMenu(ChatModel chat) { var path = ["index.md"]; try { var resources = Provider.of(context, listen: false); - var sess = await resources.fetchPage(chat.id, path, 0, 0, null); + var sess = await resources.fetchPage(chat.id, path, 0, 0, null, ""); var event = RequestedResourceEvent(chat.id, sess); chat.append(ChatEventModel(event, null), false); } catch (exception) { diff --git a/bruig/flutterui/bruig/lib/models/resources.dart b/bruig/flutterui/bruig/lib/models/resources.dart index c1129ee0..96e5f888 100644 --- a/bruig/flutterui/bruig/lib/models/resources.dart +++ b/bruig/flutterui/bruig/lib/models/resources.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:flutter/cupertino.dart'; import 'package:golib_plugin/definitions.dart'; import 'package:golib_plugin/golib_plugin.dart'; @@ -11,6 +14,9 @@ class RequestedResource extends ChangeNotifier { RequestedResource(this.uid, this.tag); } +final sectionStartRegexp = RegExp(r'--section id=([\w]+) --'); +final sectionEndRegexp = RegExp(r'--/section--'); + class PagesSession extends ChangeNotifier { final int id; PagesSession(this.id); @@ -29,6 +35,104 @@ class PagesSession extends ChangeNotifier { _loading = v; notifyListeners(); } + + String pageData() { + var utfData = currentPage?.response.data ?? Uint8List(0); + var data = utf8.decode(utfData); + + // Remove --section-- strings (these are handled internally, not at the + // markdown rendering level. + data = data.replaceAll(sectionStartRegexp, ""); + data = data.replaceAll(sectionEndRegexp, ""); + data += "\n"; + + return data; + } + + void replaceAsyncTargetWithLoading(String asyncTargetID) { + if (currentPage?.response.data == null) { + return; + } + + var data = utf8.decode(currentPage!.response.data!); + try { + var reStartPattern = r'--section id=' + asyncTargetID + r' --\n'; + var reStart = RegExp(reStartPattern); + var startPos = reStart.firstMatch(data); + if (startPos == null) { + // Did not find the target location. + return; + } + + var endPos = sectionEndRegexp.firstMatch(data.substring(startPos.end)); + if (endPos == null) { + // Unterminated section. + return; + } + + var endPosStart = + endPos.start + startPos.end; // Convert to absolute index + + // Create the new buffer, replacing the contents inside the section with + // the new data. + data = + "${data.substring(0, startPos.end)}(⏳ Loading response)\n${data.substring(endPosStart)}"; + } catch (exception) { + // Ignore any errors when trying to replace this target. + debugPrint( + "Unable to set target $asyncTargetID in page as loading: $exception"); + } + + var utfData = utf8.encode(data); + currentPage = currentPage! + .copyWith(response: currentPage!.response.copyWith(data: utfData)); + } + + void replaceAsyncTargets(List history) { + if (currentPage?.response.data == null) { + return; + } + + var data = utf8.decode(currentPage!.response.data!); + for (var fr in history) { + try { + if (fr.response.data == null) { + continue; + } + + var reStartPattern = r'--section id=' + fr.asyncTargetID + r' --\n'; + var reStart = RegExp(reStartPattern); + var startPos = reStart.firstMatch(data); + if (startPos == null) { + // Did not find the target location. + continue; + } + + var endPos = sectionEndRegexp.firstMatch(data.substring(startPos.end)); + if (endPos == null) { + // Unterminated section. + continue; + } + + var endPosStart = + endPos.start + startPos.end; // Convert to absolute index + + // Create the new buffer, replacing the contents inside the section with + // the new data. + data = data.substring(0, startPos.end) + + utf8.decode(fr.response.data!) + + data.substring(endPosStart); + } catch (exception) { + // Ignore any errors when trying to replace this target. + debugPrint( + "Unable to replace target ${fr.asyncTargetID} in page: $exception"); + } + } + + var utfData = utf8.encode(data); + currentPage = currentPage! + .copyWith(response: currentPage!.response.copyWith(data: utfData)); + } } class ResourcesModel extends ChangeNotifier { @@ -58,12 +162,16 @@ class ResourcesModel extends ChangeNotifier { } Future fetchPage(String uid, List path, int sessionID, - int parentPage, dynamic data) async { - sessionID = - await Golib.fetchResource(uid, path, null, sessionID, parentPage, data); + int parentPage, dynamic data, String asyncTargetID) async { + sessionID = await Golib.fetchResource( + uid, path, null, sessionID, parentPage, data, asyncTargetID); var sess = session(sessionID); - sess._setLoading(true); + if (asyncTargetID == "") { + sess._setLoading(true); + } else { + sess.replaceAsyncTargetWithLoading(asyncTargetID); + } return sess; } @@ -71,7 +179,31 @@ class ResourcesModel extends ChangeNotifier { var stream = Golib.fetchedResources(); await for (var fr in stream) { var sess = session(fr.sessionID); - sess.currentPage = fr; + + if (fr.asyncTargetID != "") { + List targets; + if (sess.currentPage == null) { + // Received async response without having full page, load page and + // prior history. + try { + var history = await Golib.loadFetchedResource( + fr.uid, fr.sessionID, fr.pageID); + sess.currentPage = history[0]; + targets = history.sublist(1); + } catch (exception) { + debugPrint("Exception handling fetched resource: $exception"); + continue; + } + } else { + targets = [fr]; + } + + // Replace the async target contents. + sess.replaceAsyncTargets(targets); + } else { + // Full page reload. + sess.currentPage = fr; + } } } } diff --git a/bruig/flutterui/bruig/lib/screens/feed/user_posts.dart b/bruig/flutterui/bruig/lib/screens/feed/user_posts.dart index 99d245fd..d962cbab 100644 --- a/bruig/flutterui/bruig/lib/screens/feed/user_posts.dart +++ b/bruig/flutterui/bruig/lib/screens/feed/user_posts.dart @@ -5,7 +5,6 @@ import 'package:bruig/models/feed.dart'; import 'package:bruig/models/uistate.dart'; import 'package:bruig/screens/feed.dart'; import 'package:bruig/screens/feed/feed_posts.dart'; -import 'package:collection/collection.dart'; import 'package:duration/duration.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -13,14 +12,14 @@ import 'package:bruig/components/md_elements.dart'; import 'package:golib_plugin/definitions.dart'; import 'package:bruig/components/user_context_menu.dart'; -typedef _FetchPostCB = Future Function(String pid); +typedef FetchPostCB = Future Function(String pid); class UserPostW extends StatefulWidget { final PostListItem post; final ChatModel? author; final ClientModel client; final FeedModel feed; - final _FetchPostCB fetchPost; + final FetchPostCB fetchPost; const UserPostW( this.post, this.feed, this.author, this.client, this.fetchPost, {Key? key}) diff --git a/bruig/flutterui/bruig/lib/screens/viewpage_screen.dart b/bruig/flutterui/bruig/lib/screens/viewpage_screen.dart index 577ddca7..c512454b 100644 --- a/bruig/flutterui/bruig/lib/screens/viewpage_screen.dart +++ b/bruig/flutterui/bruig/lib/screens/viewpage_screen.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:typed_data'; import 'package:bruig/components/containers.dart'; import 'package:bruig/components/empty_widget.dart'; @@ -35,9 +34,7 @@ class _ActivePageScreenState extends State<_ActivePageScreen> { Key pageKey = UniqueKey(); void updateSession() { - var data = session.currentPage?.response.data ?? Uint8List(0); - var newMdData = utf8.decode(data); - newMdData += "\n"; + var newMdData = session.pageData(); setState(() { if (newMdData != markdownData) { markdownData = newMdData; @@ -139,7 +136,7 @@ class _ViewPageScreenState extends State { var snackbar = SnackBarModel.of(context); try { - var sess = await resources.fetchPage(uid, path, 0, 0, null); + var sess = await resources.fetchPage(uid, path, 0, 0, null, ""); resources.mostRecent = sess; } catch (exception) { snackbar.error("Unable to fetch local page: $exception"); diff --git a/bruig/flutterui/plugin/lib/definitions.dart b/bruig/flutterui/plugin/lib/definitions.dart index 5d5399a9..13edc8b5 100644 --- a/bruig/flutterui/plugin/lib/definitions.dart +++ b/bruig/flutterui/plugin/lib/definitions.dart @@ -1959,9 +1959,11 @@ class FetchResourceArgs { @JsonKey(name: "parent_page", defaultValue: 0) final int parentPage; final dynamic data; + @JsonKey(name: "async_target_id") + final String asyncTargetID; FetchResourceArgs(this.uid, this.path, this.metadata, this.sessionID, - this.parentPage, this.data); + this.parentPage, this.data, this.asyncTargetID); Map toJson() => _$FetchResourceArgsToJson(this); } @@ -1999,6 +2001,9 @@ class RMFetchResourceReply { this.tag, this.status, this.meta, this.data, this.index, this.count); factory RMFetchResourceReply.fromJson(Map json) => _$RMFetchResourceReplyFromJson(json); + + RMFetchResourceReply copyWith({Uint8List? data}) => + RMFetchResourceReply(tag, status, meta, data ?? this.data, index, count); } @JsonSerializable() @@ -2076,11 +2081,46 @@ class FetchedResource { final RMFetchResource request; final RMFetchResourceReply response; + @JsonKey(name: "async_target_id") + final String asyncTargetID; + factory FetchedResource.fromJson(Map json) => _$FetchedResourceFromJson(json); - FetchedResource(this.uid, this.sessionID, this.parentPage, this.pageID, - this.requestTS, this.responseTS, this.request, this.response); + FetchedResource( + this.uid, + this.sessionID, + this.parentPage, + this.pageID, + this.requestTS, + this.responseTS, + this.request, + this.response, + this.asyncTargetID); + + FetchedResource copyWith({RMFetchResourceReply? response}) => FetchedResource( + uid, + sessionID, + parentPage, + pageID, + requestTS, + responseTS, + request, + response ?? this.response, + asyncTargetID, + ); +} + +@JsonSerializable() +class LoadFetchedResourceArgs { + final String uid; + @JsonKey(name: "session_id") + final int sessionID; + @JsonKey(name: "page_id") + final int pageID; + + LoadFetchedResourceArgs(this.uid, this.sessionID, this.pageID); + Map toJson() => _$LoadFetchedResourceArgsToJson(this); } @JsonSerializable() @@ -3102,9 +3142,10 @@ abstract class PluginPlatform { Map? metadata, int sessionID, int parentPage, - dynamic data) async { - var args = - FetchResourceArgs(uid, path, metadata, sessionID, parentPage, data); + dynamic data, + String asyncTargetID) async { + var args = FetchResourceArgs( + uid, path, metadata, sessionID, parentPage, data, asyncTargetID); return await asyncCall(CTFetchResource, args); } @@ -3185,6 +3226,18 @@ abstract class PluginPlatform { Future subscribeToAllRemotePosts() async => await asyncCall(CTSubAllPosts, null); + + Future> loadFetchedResource( + String uid, int sessionID, int pageID) async { + var res = await asyncCall( + CTLoadFetchedResource, LoadFetchedResourceArgs(uid, sessionID, pageID)); + if (res == null) { + return List.empty(); + } + return (res as List) + .map((v) => FetchedResource.fromJson(v)) + .toList(); + } } const int CTUnknown = 0x00; @@ -3317,6 +3370,7 @@ const int CTZipTimedProfilingLogs = 0x8a; const int CTListGCInvites = 0x8b; const int CTCancelDownload = 0x8c; const int CTSubAllPosts = 0x8d; +const int CTLoadFetchedResource = 0x8e; const int notificationsStartID = 0x1000; diff --git a/bruig/flutterui/plugin/lib/definitions.g.dart b/bruig/flutterui/plugin/lib/definitions.g.dart index 29281ed3..97719722 100644 --- a/bruig/flutterui/plugin/lib/definitions.g.dart +++ b/bruig/flutterui/plugin/lib/definitions.g.dart @@ -1426,6 +1426,7 @@ Map _$PostListItemToJson(PostListItem instance) => { 'id': instance.id, 'title': instance.title, + 'timestamp': instance.timestamp, }; UserPostList _$UserPostListFromJson(Map json) => UserPostList( @@ -1915,6 +1916,7 @@ FetchResourceArgs _$FetchResourceArgsFromJson(Map json) => json['session_id'] as int? ?? 0, json['parent_page'] as int? ?? 0, json['data'], + json['async_target_id'] as String, ); Map _$FetchResourceArgsToJson(FetchResourceArgs instance) => @@ -1925,6 +1927,7 @@ Map _$FetchResourceArgsToJson(FetchResourceArgs instance) => 'session_id': instance.sessionID, 'parent_page': instance.parentPage, 'data': instance.data, + 'async_target_id': instance.asyncTargetID, }; RMFetchResource _$RMFetchResourceFromJson(Map json) => @@ -2048,6 +2051,7 @@ FetchedResource _$FetchedResourceFromJson(Map json) => DateTime.parse(json['response_ts'] as String), RMFetchResource.fromJson(json['request'] as Map), RMFetchResourceReply.fromJson(json['response'] as Map), + json['async_target_id'] as String, ); Map _$FetchedResourceToJson(FetchedResource instance) => @@ -2060,6 +2064,23 @@ Map _$FetchedResourceToJson(FetchedResource instance) => 'response_ts': instance.responseTS.toIso8601String(), 'request': instance.request, 'response': instance.response, + 'async_target_id': instance.asyncTargetID, + }; + +LoadFetchedResourceArgs _$LoadFetchedResourceArgsFromJson( + Map json) => + LoadFetchedResourceArgs( + json['uid'] as String, + json['session_id'] as int, + json['page_id'] as int, + ); + +Map _$LoadFetchedResourceArgsToJson( + LoadFetchedResourceArgs instance) => + { + 'uid': instance.uid, + 'session_id': instance.sessionID, + 'page_id': instance.pageID, }; HandshakeStage _$HandshakeStageFromJson(Map json) => diff --git a/bruig/golib/command_handlers.go b/bruig/golib/command_handlers.go index a8458d2e..0f3d7e2e 100644 --- a/bruig/golib/command_handlers.go +++ b/bruig/golib/command_handlers.go @@ -1816,7 +1816,7 @@ func handleClientCmd(cc *clientCtx, cmd *cmd) (interface{}, error) { } _, err := c.FetchResource(args.UID, args.Path, args.Metadata, - args.SessionID, args.ParentPage, args.Data) + args.SessionID, args.ParentPage, args.Data, args.AsyncTargetID) return args.SessionID, err case CTHandshake: @@ -2086,6 +2086,13 @@ func handleClientCmd(cc *clientCtx, cmd *cmd) (interface{}, error) { err := c.SubscribeToAllRemotePosts(nil) return nil, err + case CTLoadFetchedResource: + var args loadFetchedResourceArgs + if err := cmd.decode(&args); err != nil { + return nil, err + } + + return c.LoadFetchedResource(args.UID, args.SessionID, args.PageID) } return nil, nil diff --git a/bruig/golib/commands.go b/bruig/golib/commands.go index 1c81ea2f..0c7c8e77 100644 --- a/bruig/golib/commands.go +++ b/bruig/golib/commands.go @@ -149,6 +149,7 @@ const ( CTListGCInvites = 0x8b CTCancelDownload = 0x8c CTSubAllPosts = 0x8d + CTLoadFetchedResource = 0x8e NTInviteReceived = 0x1001 NTInviteAccepted = 0x1002 diff --git a/bruig/golib/definitions.go b/bruig/golib/definitions.go index 4aa70f8f..1458de8d 100644 --- a/bruig/golib/definitions.go +++ b/bruig/golib/definitions.go @@ -439,12 +439,19 @@ type invitation struct { } type fetchResourceArgs struct { - UID clientintf.UserID `json:"uid"` - Path []string `json:"path"` - Metadata map[string]string `json:"metadata,omitempty"` - SessionID clientintf.PagesSessionID `json:"session_id"` - ParentPage clientintf.PagesSessionID `json:"parent_page"` - Data json.RawMessage `json:"data"` + UID clientintf.UserID `json:"uid"` + Path []string `json:"path"` + Metadata map[string]string `json:"metadata,omitempty"` + SessionID clientintf.PagesSessionID `json:"session_id"` + ParentPage clientintf.PagesSessionID `json:"parent_page"` + Data json.RawMessage `json:"data"` + AsyncTargetID string `json:"async_target_id"` +} + +type loadFetchedResourceArgs struct { + UID clientintf.UserID `json:"uid"` + SessionID clientintf.PagesSessionID `json:"session_id"` + PageID clientintf.PagesSessionID `json:"page_id"` } type simpleStoreOrder struct { diff --git a/client/client_resources.go b/client/client_resources.go index 93530b1c..705806ed 100644 --- a/client/client_resources.go +++ b/client/client_resources.go @@ -15,6 +15,9 @@ import ( "github.com/decred/slog" ) +// NewPagesSession creates a new namespace for resource requests. A "session" +// is roughly equivalent to a browser tab: multiple requests may be performed +// associated with a single session. func (c *Client) NewPagesSession() (clientintf.PagesSessionID, error) { var id clientintf.PagesSessionID err := c.dbUpdate(func(tx clientdb.ReadWriteTx) error { @@ -68,7 +71,9 @@ func (c *Client) FetchLocalResource(path []string, meta map[string]string, data // resource is returned the ResourceFetched handler will be called with // the response using the returned tag. func (c *Client) FetchResource(uid UserID, path []string, meta map[string]string, - sess, parentPage clientintf.PagesSessionID, data json.RawMessage) (rpc.ResourceTag, error) { + sess, parentPage clientintf.PagesSessionID, data json.RawMessage, + asyncTargetID string) (rpc.ResourceTag, error) { + ru, err := c.UserByID(uid) if err != nil { return 0, err @@ -81,7 +86,8 @@ func (c *Client) FetchResource(uid UserID, path []string, meta map[string]string } err = c.dbUpdate(func(tx clientdb.ReadWriteTx) error { - return c.db.StoreResourceRequest(tx, uid, sess, parentPage, &rm) + return c.db.StoreResourceRequest(tx, uid, sess, parentPage, + &rm, asyncTargetID) }) if err != nil { return 0, err @@ -174,3 +180,18 @@ func (c *Client) handleFetchResourceReply(ru *RemoteUser, frr rpc.RMFetchResourc c.ntfns.notifyResourceFetched(ru, fr, sess) return nil } + +// LoadFetchedResource loads an already fetched resource for the given session +// and page. The first element of the returned slice is the page, the others +// are async requests that originated from the same page. +func (c *Client) LoadFetchedResource(uid UserID, session, + page clientintf.PagesSessionID) ([]*clientdb.FetchedResource, error) { + + var res []*clientdb.FetchedResource + err := c.dbView(func(tx clientdb.ReadTx) error { + var err error + res, err = c.db.LoadFetchedResource(tx, uid, session, page) + return err + }) + return res, err +} diff --git a/client/clientdb/interface.go b/client/clientdb/interface.go index afacc2bc..9b966dfa 100644 --- a/client/clientdb/interface.go +++ b/client/clientdb/interface.go @@ -349,36 +349,40 @@ type TipUserAttempt struct { // ResourceRequest is a serialized request for a resource. type ResourceRequest struct { - UID UserID `json:"uid"` - Timestamp time.Time `json:"timestamp"` - Request rpc.RMFetchResource `json:"request"` - SesssionID clientintf.PagesSessionID `json:"session_id"` - ParentPage clientintf.PagesSessionID `json:"parent_page"` + UID UserID `json:"uid"` + Timestamp time.Time `json:"timestamp"` + Request rpc.RMFetchResource `json:"request"` + SesssionID clientintf.PagesSessionID `json:"session_id"` + ParentPage clientintf.PagesSessionID `json:"parent_page"` + AsyncTargetID string `json:"async_target_id"` } // FetchedResource is the full information about a fetched resource from a // remote client. type FetchedResource struct { - UID UserID `json:"uid"` - SessionID clientintf.PagesSessionID `json:"session_id"` - ParentPage clientintf.PagesSessionID `json:"parent_page"` - PageID clientintf.PagesSessionID `json:"page_id"` - RequestTS time.Time `json:"request_ts"` - ResponseTS time.Time `json:"response_ts"` - Request rpc.RMFetchResource `json:"request"` - Response rpc.RMFetchResourceReply `json:"response"` + UID UserID `json:"uid"` + SessionID clientintf.PagesSessionID `json:"session_id"` + ParentPage clientintf.PagesSessionID `json:"parent_page"` + PageID clientintf.PagesSessionID `json:"page_id"` + RequestTS time.Time `json:"request_ts"` + ResponseTS time.Time `json:"response_ts"` + Request rpc.RMFetchResource `json:"request"` + Response rpc.RMFetchResourceReply `json:"response"` + AsyncTargetID string `json:"async_target_id"` } // PageSessionOverviewRequest is the overview of a fetch resource request. type PageSessionOverviewRequest struct { - UID clientintf.UserID `json:"uid"` - Tag rpc.ResourceTag `json:"tag"` + UID clientintf.UserID `json:"uid"` + Tag rpc.ResourceTag `json:"tag"` + AsyncTargetID string `json:"async_target_id"` } // PageSessionOverviewResponse is the overview of a fetch resurce response. type PageSessionOverviewResponse struct { - ID clientintf.PagesSessionID `json:"id"` - Parent clientintf.PagesSessionID `json:"parent"` + ID clientintf.PagesSessionID `json:"id"` + Parent clientintf.PagesSessionID `json:"parent"` + AsyncTargetID string `json:"async_target_id"` } // PageSessionOverview stores the overview of a pages session navigation. @@ -389,8 +393,12 @@ type PageSessionOverview struct { LastRequestTS time.Time `json:"last_request_ts"` } -func (o *PageSessionOverview) append(parent, id clientintf.PagesSessionID) { - o.Responses = append(o.Responses, PageSessionOverviewResponse{Parent: parent, ID: id}) +func (o *PageSessionOverview) appendResponse(parent, id clientintf.PagesSessionID, asyncId string) { + o.Responses = append(o.Responses, PageSessionOverviewResponse{ + Parent: parent, + ID: id, + AsyncTargetID: asyncId, + }) o.LastResponseTS = time.Now() } @@ -403,11 +411,54 @@ func (o *PageSessionOverview) removeRequest(uid clientintf.UserID, tag rpc.Resou } } -func (o *PageSessionOverview) appendRequest(uid clientintf.UserID, tag rpc.ResourceTag) { - o.Requests = append(o.Requests, PageSessionOverviewRequest{UID: uid, Tag: tag}) +func (o *PageSessionOverview) appendRequest(uid clientintf.UserID, tag rpc.ResourceTag, asyncId string) { + o.Requests = append(o.Requests, PageSessionOverviewRequest{ + UID: uid, + Tag: tag, + AsyncTargetID: asyncId, + }) o.LastRequestTS = time.Now() } +// pageAndAsyncChildren returns the page response (if it exists) and any async +// children derived from it. +// +// This returns at most one child for each async id (the latest one). +func (o *PageSessionOverview) pageAndAsyncChildren(id clientintf.PagesSessionID) []*PageSessionOverviewResponse { + var res []*PageSessionOverviewResponse + asyncIds := make(map[string]int) // Tracks index inside res + + for i := range o.Responses { + r := &o.Responses[i] + if r.ID != id && r.Parent != id { + continue + } + + // Handle the page itself. + if r.ID == id { + if len(res) == 0 { + res = append(res, r) + } else { + res[0] = r + } + continue + } + + // Handle async requests in the page. + if r.AsyncTargetID == "" { + continue + } + if idxToReplace, ok := asyncIds[r.AsyncTargetID]; ok { + res[idxToReplace] = r + } else { + res = append(res, r) + asyncIds[r.AsyncTargetID] = len(res) - 1 + } + } + + return res +} + type PMLogEntry struct { Message string `json:"message"` From string `json:"from"` diff --git a/client/clientdb/resources.go b/client/clientdb/resources.go index b622f231..a464f9f4 100644 --- a/client/clientdb/resources.go +++ b/client/clientdb/resources.go @@ -2,6 +2,7 @@ package clientdb import ( "errors" + "fmt" "os" "path" "path/filepath" @@ -27,7 +28,8 @@ func (db *DB) NewPagesSession(tx ReadWriteTx) (clientintf.PagesSessionID, error) // StoreResourceRequest stores the specified requested resource. This generates // a random tag for the request, which is set in the passed request Tag field. func (db *DB) StoreResourceRequest(tx ReadWriteTx, uid UserID, - sess, parentPage clientintf.PagesSessionID, req *rpc.RMFetchResource) error { + sess, parentPage clientintf.PagesSessionID, req *rpc.RMFetchResource, + asyncTargetID string) error { userReqsDir := filepath.Join(db.root, inboundDir, uid.String(), reqResourcesDir) @@ -41,11 +43,12 @@ func (db *DB) StoreResourceRequest(tx ReadWriteTx, uid UserID, req.Tag = tag rr := ResourceRequest{ - UID: uid, - SesssionID: sess, - ParentPage: parentPage, - Request: *req, - Timestamp: time.Now(), + UID: uid, + SesssionID: sess, + ParentPage: parentPage, + Request: *req, + Timestamp: time.Now(), + AsyncTargetID: asyncTargetID, } if err := db.saveJsonFile(filename, rr); err != nil { return err @@ -56,7 +59,8 @@ func (db *DB) StoreResourceRequest(tx ReadWriteTx, uid UserID, if err != nil { return err } - overv.appendRequest(uid, tag) + overv.appendRequest(uid, tag, asyncTargetID) + if err := db.saveResourcesSessionOverview(sess, &overv); err != nil { return err } @@ -124,14 +128,15 @@ func (db *DB) StoreFetchedResource(tx ReadWriteTx, uid UserID, tag rpc.ResourceT // Store the fetched resource. fr = FetchedResource{ - UID: uid, - SessionID: req.SesssionID, - ParentPage: req.ParentPage, - PageID: clientintf.PagesSessionID(pageID), - RequestTS: req.Timestamp, - ResponseTS: time.Now(), - Request: req.Request, - Response: reply, + UID: uid, + SessionID: req.SesssionID, + ParentPage: req.ParentPage, + PageID: clientintf.PagesSessionID(pageID), + RequestTS: req.Timestamp, + ResponseTS: time.Now(), + Request: req.Request, + Response: reply, + AsyncTargetID: req.AsyncTargetID, } fname := filepath.Join(sessionDir, pageFnamePattern.FilenameFor(pageID)) @@ -146,7 +151,8 @@ func (db *DB) StoreFetchedResource(tx ReadWriteTx, uid UserID, tag rpc.ResourceT return fr, sess, err } sess.removeRequest(uid, tag) - sess.append(req.ParentPage, clientintf.PagesSessionID(pageID)) + sess.appendResponse(req.ParentPage, clientintf.PagesSessionID(pageID), + req.AsyncTargetID) if err := db.saveResourcesSessionOverview(req.SesssionID, &sess); err != nil { return fr, sess, err } @@ -158,3 +164,37 @@ func (db *DB) StoreFetchedResource(tx ReadWriteTx, uid UserID, tag rpc.ResourceT return fr, sess, nil } + +// LoadFetchedResource loads resources that have already been fetched from a +// remote host. If the requested page has async resources that were already +// fetched, they are returned as well. +func (db *DB) LoadFetchedResource(tx ReadTx, uid UserID, sessionId, pageId clientintf.PagesSessionID) ([]*FetchedResource, error) { + sess, err := db.readResourcesSessionOverview(sessionId) + if err != nil { + return nil, err + } + + pages := sess.pageAndAsyncChildren(pageId) + if len(pages) == 0 { + return nil, fmt.Errorf("%w: page %s does not have a response", + ErrNotFound, pageId) + } + + sessionDir := filepath.Join(db.root, pageSessionsDir, pageSessDirPattern.FilenameFor(uint64(sessionId))) + + res := make([]*FetchedResource, 0, len(pages)) + for _, r := range pages { + fname := filepath.Join(sessionDir, pageFnamePattern.FilenameFor(uint64(r.ID))) + fr := new(FetchedResource) + err := db.readJsonFile(fname, fr) + if err != nil { + db.log.Warnf("Unable to load sesion resource %s/%s: %v", + sessionId, r.ID, err) + continue + } + + res = append(res, fr) + } + + return res, nil +} diff --git a/client/resources/simplestore/handlers.go b/client/resources/simplestore/handlers.go index 23f9154e..4285082c 100644 --- a/client/resources/simplestore/handlers.go +++ b/client/resources/simplestore/handlers.go @@ -581,3 +581,20 @@ func (s *Store) handleOrderAddComment(ctx context.Context, uid clientintf.UserID }, nil } + +func (s *Store) handleStaticRequest(ctx context.Context, uid clientintf.UserID, + request *rpc.RMFetchResource) (*rpc.RMFetchResourceReply, error) { + + page := request.Path[1] + ".tmpl" + + w := &bytes.Buffer{} + err := s.tmpl.ExecuteTemplate(w, page, nil) + if err != nil { + return nil, fmt.Errorf("unable to execute static template: %v", err) + } + + return &rpc.RMFetchResourceReply{ + Data: w.Bytes(), + Status: rpc.ResourceStatusOk, + }, nil +} diff --git a/client/resources/simplestore/simplestore.go b/client/resources/simplestore/simplestore.go index 7fce033c..9e66f36a 100644 --- a/client/resources/simplestore/simplestore.go +++ b/client/resources/simplestore/simplestore.go @@ -127,27 +127,31 @@ func (s *Store) reloadStore() error { tmpl := template.New("*root") // Parse templates. - filenames, err := filepath.Glob(filepath.Join(s.root, "*.tmpl")) - if err != nil { - return err - } - for _, filename := range filenames { - if filepath.Ext(filename) != ".tmpl" { - continue - } - rawBytes, err := os.ReadFile(filename) + dirs := []string{s.root, filepath.Join(s.root, "static")} + for _, dir := range dirs { + filenames, err := filepath.Glob(filepath.Join(dir, "*.tmpl")) if err != nil { return err } - data := string(rawBytes) - data = resources.ProcessEmbeds(data, - s.root, s.log) + for _, filename := range filenames { + if filepath.Ext(filename) != ".tmpl" { + continue + } + rawBytes, err := os.ReadFile(filename) + if err != nil { + return err + } + data := string(rawBytes) + data = resources.ProcessEmbeds(data, + s.root, s.log) - t := tmpl.New(filepath.Base(filename)) - _, err = t.Parse(data) - if err != nil { - return fmt.Errorf("unable to parse template %s: %v", - filename, err) + s.log.Debugf("Reloading demplate %s (name %s)", filename, filepath.Base(filename)) + t := tmpl.New(filepath.Base(filename)) + _, err = t.Parse(data) + if err != nil { + return fmt.Errorf("unable to parse template %s: %v", + filename, err) + } } } @@ -241,6 +245,8 @@ func (s *Store) Fulfill(ctx context.Context, uid clientintf.UserID, return s.handleOrderStatus(ctx, uid, request) case len(request.Path) == 2 && request.Path[0] == "orderaddcomment": return s.handleOrderAddComment(ctx, uid, request) + case len(request.Path) == 2 && request.Path[0] == "static": + return s.handleStaticRequest(ctx, uid, request) default: return s.handleNotFound(ctx, uid, request) } diff --git a/internal/assert/asserts.go b/internal/assert/asserts.go index aa14755c..25f30c86 100644 --- a/internal/assert/asserts.go +++ b/internal/assert/asserts.go @@ -85,6 +85,14 @@ func DeepEqual[T any](t testing.TB, got, want T) { } } +// NotDeepEqual asserts got is not reflect.DeepEqual to want. +func NotDeepEqual[T any](t testing.TB, got, want T) { + t.Helper() + if reflect.DeepEqual(got, want) { + t.Fatalf("Unexpected equal values: got %v, want %v", got, want) + } +} + // ErrorIs asserts that errors.Is(got, want). func ErrorIs(t testing.TB, got, want error) { t.Helper() diff --git a/internal/e2etests/resources_test.go b/internal/e2etests/resources_test.go index 4bd948a2..ba0e2a04 100644 --- a/internal/e2etests/resources_test.go +++ b/internal/e2etests/resources_test.go @@ -11,6 +11,7 @@ import ( "github.com/companyzero/bisonrelay/rpc" ) +// TestFetchesFixedResource tests fetching a fixed resource. func TestFetchesFixedResource(t *testing.T) { // Setup Alice and Bob and have them KX. tcfg := testScaffoldCfg{} @@ -39,7 +40,8 @@ func TestFetchesFixedResource(t *testing.T) { })) // Have Bob ask for the resource. - tag, err := bob.FetchResource(alice.PublicID(), resourcePath, nil, 0, 0, nil) + tag, err := bob.FetchResource(alice.PublicID(), resourcePath, nil, 0, + 0, nil, "") assert.NilErr(t, err) // Bob receives the resource. @@ -49,9 +51,148 @@ func TestFetchesFixedResource(t *testing.T) { // Have Bob ask for a resource that does not exist. bogusPath := []string{"does", "not", "exist"} - _, err = bob.FetchResource(alice.PublicID(), bogusPath, nil, 0, 0, nil) + _, err = bob.FetchResource(alice.PublicID(), bogusPath, nil, 0, 0, + nil, "") assert.NilErr(t, err) // Bob does not receive a reply. assert.ChanNotWritten(t, chanResReply, time.Second) } + +// TestFetchesMultipleAsyncTargets tests fetching multiple async targets +// starting from a base page (simulates performing multiple actions in a single +// page). +func TestFetchesMultipleAsyncTargets(t *testing.T) { + // Setup Alice and Bob and have them KX. + tcfg := testScaffoldCfg{} + ts := newTestScaffold(t, tcfg) + alice := ts.newClient("alice") + bob := ts.newClient("bob") + ts.kxUsers(alice, bob) + + // Static paths and resources data. + var ( + rootPath = resources.SplitPath("/path/to/root") + rootData = []byte("root static data") + asyncPath1 = resources.SplitPath("/async/target/one") + asyncData1 = []byte("async data one") + asyncPath2 = resources.SplitPath("/async/target/two") + asyncData2 = []byte("async data two") + asyncPath1Req2 = resources.SplitPath("/async/target/one second request") + asyncData1Req2 = []byte("async data one second request") + asyncTarget1 = "async target 1" + asyncTarget2 = "async target 2" + ) + + // Setup Alice's resources handler. + alice.modifyHandlers(func() { + r := resources.NewRouter() + r.BindExactPath(rootPath, &resources.StaticResource{Data: rootData}) + r.BindExactPath(asyncPath1, &resources.StaticResource{Data: asyncData1}) + r.BindExactPath(asyncPath2, &resources.StaticResource{Data: asyncData2}) + r.BindExactPath(asyncPath1Req2, &resources.StaticResource{Data: asyncData1Req2}) + alice.resourcesProvider = r + }) + + // Setup Bob's fetched resource handler. + chanResReply := make(chan clientdb.FetchedResource, 1) + bob.handle(client.OnResourceFetchedNtfn(func(user *client.RemoteUser, + fr clientdb.FetchedResource, sess clientdb.PageSessionOverview) { + chanResReply <- fr + })) + + sessID, err := bob.NewPagesSession() + assert.NilErr(t, err) + + // Bob asks for the root resource. + tagRoot, err := bob.FetchResource(alice.PublicID(), rootPath, nil, sessID, 0, nil, "") + assert.NilErr(t, err) + + // Bob receives the resource. + resRoot := assert.ChanWritten(t, chanResReply) + assert.DeepEqual(t, resRoot.Response.Tag, tagRoot) + assert.DeepEqual(t, resRoot.Response.Data, rootData) + + // Alice goes offline. + assertGoesOffline(t, alice) + + // Bob asks for two async resources. These simulate asking for two + // async/background actions that were available in the root page. + tagAsync1, err := bob.FetchResource(alice.PublicID(), asyncPath1, nil, + sessID, resRoot.PageID, nil, asyncTarget1) + assert.NilErr(t, err) + tagAsync2, err := bob.FetchResource(alice.PublicID(), asyncPath2, nil, + sessID, resRoot.PageID, nil, asyncTarget2) + assert.NilErr(t, err) + + // Add a hook in Alice to assert the replies are going out. + aliceSentResReply := make(chan struct{}, 5) + alice.handle(client.OnRMSent(func(ru *client.RemoteUser, rm interface{}) { + if _, ok := rm.(rpc.RMFetchResourceReply); ok { + aliceSentResReply <- struct{}{} + } + })) + + // Bob goes offline. Alice comes back online and send their replies. + assertEmptyRMQ(t, bob) + assertGoesOffline(t, bob) + + time.Sleep(time.Second) + assertGoesOnline(t, alice) + assert.ChanWritten(t, aliceSentResReply) + assert.ChanWritten(t, aliceSentResReply) + assertEmptyRMQ(t, alice) + + // Bob comes back online and receives the replies. + assertGoesOnline(t, bob) + resAsync1 := assert.ChanWritten(t, chanResReply) + resAsync2 := assert.ChanWritten(t, chanResReply) + if resAsync1.Request.Tag == tagAsync2 { + // Handle case if replies are received out of order. + resAsync1, resAsync2 = resAsync2, resAsync1 + } + + // Verifiy replies. + assert.DeepEqual(t, resAsync1.Response.Tag, tagAsync1) + assert.DeepEqual(t, resAsync1.Response.Data, asyncData1) + assert.DeepEqual(t, resAsync1.AsyncTargetID, asyncTarget1) + assert.DeepEqual(t, resAsync1.ParentPage, resRoot.PageID) + assert.NotDeepEqual(t, resAsync1.PageID, resRoot.PageID) + assert.DeepEqual(t, resAsync2.Response.Tag, tagAsync2) + assert.DeepEqual(t, resAsync2.Response.Data, asyncData2) + assert.DeepEqual(t, resAsync2.AsyncTargetID, asyncTarget2) + assert.DeepEqual(t, resAsync2.ParentPage, resRoot.PageID) + assert.NotDeepEqual(t, resAsync2.PageID, resRoot.PageID) + assert.NotDeepEqual(t, resAsync1.PageID, resAsync2.PageID) + + // Double check Bob can load the original page plus all async requests. + loaded, err := bob.LoadFetchedResource(alice.PublicID(), sessID, resRoot.PageID) + assert.NilErr(t, err) + assert.DeepEqual(t, len(loaded), 3) + assert.DeepEqual(t, loaded[0].PageID, resRoot.PageID) + assert.DeepEqual(t, loaded[1].PageID, resAsync1.PageID) + assert.DeepEqual(t, loaded[2].PageID, resAsync2.PageID) + + // Perform a second async request to the same async target (but a + // different action, which returns different data). + tagAsync1Req2, err := bob.FetchResource(alice.PublicID(), asyncPath1Req2, nil, + sessID, resRoot.PageID, nil, asyncTarget1) + assert.NilErr(t, err) + assert.ChanWritten(t, aliceSentResReply) + resAsync1Req2 := assert.ChanWritten(t, chanResReply) + assert.DeepEqual(t, resAsync1Req2.Response.Tag, tagAsync1Req2) + assert.DeepEqual(t, resAsync1Req2.Response.Data, asyncData1Req2) + assert.DeepEqual(t, resAsync1Req2.AsyncTargetID, asyncTarget1) + assert.DeepEqual(t, resAsync1Req2.ParentPage, resRoot.PageID) + assert.NotDeepEqual(t, resAsync1Req2.PageID, resRoot.PageID) + assert.NotDeepEqual(t, resAsync1Req2.PageID, resAsync1.PageID) + + // Double check Bob can load the original page plus all async requests. + // The second request replaces the first request target async. + loaded, err = bob.LoadFetchedResource(alice.PublicID(), sessID, resRoot.PageID) + assert.NilErr(t, err) + assert.DeepEqual(t, len(loaded), 3) + assert.DeepEqual(t, loaded[0].PageID, resRoot.PageID) + assert.DeepEqual(t, loaded[1].PageID, resAsync1Req2.PageID) + assert.DeepEqual(t, loaded[2].PageID, resAsync2.PageID) +}