diff --git a/src/components/view/view-event-list.tsx b/src/components/view/view-event-list.tsx index 3393d425..915c84fe 100644 --- a/src/components/view/view-event-list.tsx +++ b/src/components/view/view-event-list.tsx @@ -17,6 +17,7 @@ import { RTCConnection, TlsTunnel } from '../../types'; +import { UiStore } from '../../model/ui/ui-store'; import { getSummaryColor, @@ -54,6 +55,7 @@ interface ViewEventListProps { isPaused: boolean; contextMenuBuilder: ViewEventContextMenuBuilder; + uiStore: UiStore; moveSelection: (distance: number) => void; onSelected: (event: CollectedEvent | undefined) => void; @@ -879,19 +881,37 @@ export class ViewEventList extends React.Component { if (!listWindow) return true; // This means no rows, so we are effectively at the bottom else return (listWindow.scrollTop + SCROLL_BOTTOM_MARGIN) >= (listWindow.scrollHeight - listWindow.offsetHeight); } - private wasListAtBottom = true; private updateScrolledState = () => { requestAnimationFrame(() => { // Measure async, once the scroll has actually happened this.wasListAtBottom = this.isListAtBottom(); + + // Only save scroll position after we've restored the initial state + if (this.hasRestoredInitialState) { + const listWindow = this.listBodyRef.current?.parentElement; + if (listWindow) { + this.props.uiStore.setViewScrollPosition(listWindow.scrollTop); + } + } }); } + private hasRestoredInitialState = false; componentDidMount() { - this.updateScrolledState(); + // Don't save scroll state immediately - wait until we've restored first + + // Use a more aggressive delay to ensure DOM is fully ready + setTimeout(() => { + this.restoreScrollPosition(); + + // Only start tracking scroll changes after we've restored + setTimeout(() => { + this.hasRestoredInitialState = true; + }, 100); + }, 100); } - componentDidUpdate() { + componentDidUpdate(prevProps: ViewEventListProps) { if (this.listBodyRef.current?.parentElement?.contains(document.activeElement)) { // If we previously had something here focused, and we've updated, update // the focus too, to make sure it's in the right place. @@ -901,7 +921,29 @@ export class ViewEventList extends React.Component { // If we previously were scrolled to the bottom of the list, but now we're not, // scroll there again ourselves now. if (this.wasListAtBottom && !this.isListAtBottom()) { - this.listRef.current?.scrollToItem(this.props.events.length - 1); + this.listRef.current?.scrollToItem(this.props.events.length - 1); + } else if (prevProps.selectedEvent !== this.props.selectedEvent && this.props.selectedEvent) { + // If the selected event changed and we have a selected event, scroll to it + // This handles restoring the selected event when returning to the tab + this.scrollToEvent(this.props.selectedEvent); + } else if (prevProps.filteredEvents.length !== this.props.filteredEvents.length) { + // If the filtered events changed (e.g., new events loaded), try to restore scroll position + setTimeout(() => { + this.restoreScrollPosition(); + }, 50); + } + } + + private restoreScrollPosition = () => { + // Only restore if we have a saved position + const savedPosition = this.props.uiStore.viewScrollPosition; + if (savedPosition > 0) { + const listWindow = this.listBodyRef.current?.parentElement; + if (listWindow) { // Only restore if we're not close to the current position (avoid unnecessary scrolling) + if (Math.abs(listWindow.scrollTop - savedPosition) > 10) { + listWindow.scrollTop = savedPosition; + } + } } } @@ -1005,5 +1047,12 @@ export class ViewEventList extends React.Component { } event.preventDefault(); + } // Public method to force scroll and selection restoration + public restoreViewState = () => { + if (this.props.selectedEvent) { + this.scrollToEvent(this.props.selectedEvent); + } else { + this.restoreScrollPosition(); + } } } \ No newline at end of file diff --git a/src/components/view/view-page.tsx b/src/components/view/view-page.tsx index c1618c20..8611227c 100644 --- a/src/components/view/view-page.tsx +++ b/src/components/view/view-page.tsx @@ -207,11 +207,13 @@ class ViewPage extends React.Component { filteredEventCount: [filteredEvents.length, events.length] }; } - @computed get selectedEvent() { + // First try to use the URL-based eventId, then fallback to the persisted selection + const targetEventId = this.props.eventId || this.props.uiStore.selectedEventId; + return _.find(this.props.eventsStore.events, { - id: this.props.eventId + id: targetEventId }); } @@ -240,12 +242,16 @@ class ViewPage extends React.Component { this.onBuildRuleFromExchange, this.onPrepareToResendRequest ); - componentDidMount() { // After first render, scroll to the selected event (or the end of the list) by default: requestAnimationFrame(() => { if (this.props.eventId && this.selectedEvent) { this.onScrollToCenterEvent(this.selectedEvent); + } else if (!this.props.eventId && this.props.uiStore.selectedEventId) { + // If no URL eventId but we have a persisted selection, restore it + setTimeout(() => { + this.listRef.current?.restoreViewState(); + }, 100); } else { this.onScrollToEnd(); } @@ -327,6 +333,18 @@ class ViewPage extends React.Component { }) ); } + componentWillUnmount() { + // Component is unmounting + } + + componentDidUpdate(prevProps: ViewPageProps) { + // Only clear persisted selection if we're explicitly navigating to a different event via URL + // Don't clear it when going from eventId to no eventId (which happens when clearing selection) + if (this.props.eventId && prevProps.eventId && this.props.eventId !== prevProps.eventId) { + // Clear persisted selection only when explicitly navigating between different events via URL + this.props.uiStore.setSelectedEventId(undefined); + } + } isSendAvailable() { return versionSatisfies(serverVersion.value as string, SERVER_SEND_API_SUPPORTED); @@ -447,8 +465,8 @@ class ViewPage extends React.Component { moveSelection={this.moveSelection} onSelected={this.onSelected} - contextMenuBuilder={this.contextMenuBuilder} + uiStore={this.props.uiStore} ref={this.listRef} /> @@ -488,9 +506,11 @@ class ViewPage extends React.Component { onSearchFiltersConsidered(filters: FilterSet | undefined) { this.searchFiltersUnderConsideration = filters; } - @action.bound onSelected(event: CollectedEvent | undefined) { + // Persist the selected event to UiStore for tab switching + this.props.uiStore.setSelectedEventId(event?.id); + this.props.navigate(event ? `/view/${event.id}` : '/view' diff --git a/src/model/ui/ui-store.ts b/src/model/ui/ui-store.ts index 8a40120a..ae5a6ebb 100644 --- a/src/model/ui/ui-store.ts +++ b/src/model/ui/ui-store.ts @@ -1,496 +1,550 @@ -import * as _ from 'lodash'; -import * as React from 'react'; -import { observable, action, autorun, computed, observe } from 'mobx'; - -import { Theme, ThemeName, Themes } from '../../styles'; -import { lazyObservablePromise } from '../../util/observable'; -import { persist, hydrate } from '../../util/mobx-persist/persist'; -import { unreachableCheck, UnreachableCheck } from '../../util/error'; - -import { AccountStore } from '../account/account-store'; -import { emptyFilterSet, FilterSet } from '../filters/search-filters'; -import { DesktopApi } from '../../services/desktop-api'; +import * as _ from "lodash"; +import * as React from "react"; +import { observable, action, autorun, computed, observe } from "mobx"; + +import { Theme, ThemeName, Themes } from "../../styles"; +import { lazyObservablePromise } from "../../util/observable"; +import { persist, hydrate } from "../../util/mobx-persist/persist"; +import { unreachableCheck, UnreachableCheck } from "../../util/error"; + +import { AccountStore } from "../account/account-store"; +import { emptyFilterSet, FilterSet } from "../filters/search-filters"; +import { DesktopApi } from "../../services/desktop-api"; import { - ContextMenuState, - ContextMenuItem, - ContextMenuOption, - buildNativeContextMenuItems -} from './context-menu'; -import { tryParseJson } from '../../util'; + ContextMenuState, + ContextMenuItem, + ContextMenuOption, + buildNativeContextMenuItems, +} from "./context-menu"; +import { tryParseJson } from "../../util"; const VIEW_CARD_KEYS = [ - 'api', + "api", - 'request', - 'requestBody', - 'requestTrailers', - 'response', - 'responseBody', - 'responseTrailers', + "request", + "requestBody", + "requestTrailers", + "response", + "responseBody", + "responseTrailers", - 'webSocketMessages', - 'webSocketClose', + "webSocketMessages", + "webSocketClose", - 'rtcConnection', - 'rtcSessionOffer', - 'rtcSessionAnswer', + "rtcConnection", + "rtcSessionOffer", + "rtcSessionAnswer", - 'performance', - 'export' + "performance", + "export", ] as const; -type ViewCardKey = typeof VIEW_CARD_KEYS[number]; +type ViewCardKey = (typeof VIEW_CARD_KEYS)[number]; const EXPANDABLE_VIEW_CARD_KEYS = [ - 'requestBody', - 'responseBody', - 'webSocketMessages' + "requestBody", + "responseBody", + "webSocketMessages", ] as const; -export type ExpandableViewCardKey = typeof EXPANDABLE_VIEW_CARD_KEYS[number]; +export type ExpandableViewCardKey = (typeof EXPANDABLE_VIEW_CARD_KEYS)[number]; const isExpandableViewCard = (key: any): key is ExpandableViewCardKey => - EXPANDABLE_VIEW_CARD_KEYS.includes(key); + EXPANDABLE_VIEW_CARD_KEYS.includes(key); const SEND_CARD_KEYS = [ - 'requestHeaders', - 'requestBody', - 'responseHeaders', - 'responseBody' + "requestHeaders", + "requestBody", + "responseHeaders", + "responseBody", ] as const; -type SendCardKey = typeof SEND_CARD_KEYS[number]; +type SendCardKey = (typeof SEND_CARD_KEYS)[number]; -const isSendRequestCard = (key: SendCardKey): key is 'requestHeaders' | 'requestBody' => - key.startsWith('request'); +const isSendRequestCard = ( + key: SendCardKey +): key is "requestHeaders" | "requestBody" => key.startsWith("request"); -const isSentResponseCard = (key: SendCardKey): key is 'responseHeaders' | 'responseBody' => - key.startsWith('response'); +const isSentResponseCard = ( + key: SendCardKey +): key is "responseHeaders" | "responseBody" => key.startsWith("response"); export type ContentPerspective = - | 'client' // What did the client send (original) & receive (after transform) - | 'server' // What did the server receive (after transform) & send (original)? - | 'transformed' // What was the request & resposne after both transforms? - | 'original' // What was the request & response before transforms? + | "client" // What did the client send (original) & receive (after transform) + | "server" // What did the server receive (after transform) & send (original)? + | "transformed" // What was the request & resposne after both transforms? + | "original"; // What was the request & response before transforms? const SEND_REQUEST_CARD_KEYS = SEND_CARD_KEYS.filter(isSendRequestCard); const SENT_RESPONSE_CARD_KEYS = SEND_CARD_KEYS.filter(isSentResponseCard); const EXPANDABLE_SEND_REQUEST_CARD_KEYS = [ - 'requestHeaders', - 'requestBody', + "requestHeaders", + "requestBody", ] as const; -type ExpandableSendRequestCardKey = typeof EXPANDABLE_SEND_REQUEST_CARD_KEYS[number]; +type ExpandableSendRequestCardKey = + (typeof EXPANDABLE_SEND_REQUEST_CARD_KEYS)[number]; const EXPANDABLE_SENT_RESPONSE_CARD_KEYS = [ - 'responseHeaders', - 'responseBody' + "responseHeaders", + "responseBody", ] as const; -type ExpandableSentResponseCardKey = typeof EXPANDABLE_SENT_RESPONSE_CARD_KEYS[number]; +type ExpandableSentResponseCardKey = + (typeof EXPANDABLE_SENT_RESPONSE_CARD_KEYS)[number]; -type ExpandableSendCardKey = ExpandableSendRequestCardKey | ExpandableSentResponseCardKey; +type ExpandableSendCardKey = + | ExpandableSendRequestCardKey + | ExpandableSentResponseCardKey; const isExpandableSendCard = (key: any): key is ExpandableSendCardKey => - EXPANDABLE_SEND_REQUEST_CARD_KEYS.includes(key) || - EXPANDABLE_SENT_RESPONSE_CARD_KEYS.includes(key); - -const SETTINGS_CARD_KEYS =[ - 'account', - 'proxy', - 'connection', - 'api', - 'themes' + EXPANDABLE_SEND_REQUEST_CARD_KEYS.includes(key) || + EXPANDABLE_SENT_RESPONSE_CARD_KEYS.includes(key); + +const SETTINGS_CARD_KEYS = [ + "account", + "proxy", + "connection", + "api", + "themes", ] as const; -type SettingsCardKey = typeof SETTINGS_CARD_KEYS[number]; +type SettingsCardKey = (typeof SETTINGS_CARD_KEYS)[number]; type CustomTheme = Partial & { - name: string; - extends: ThemeName; + name: string; + extends: ThemeName; }; export class UiStore { - - constructor( - private accountStore: AccountStore - ) { } - - readonly initialized = lazyObservablePromise(async () => { - await this.accountStore.initialized; - - autorun(() => { - // Any time the theme changes, update the HTML background to match - document.querySelector('html')!.style.backgroundColor = this.theme.containerBackground; - - // Persist the background colour standalone, so we can easily access it - // from the index.html loading script, whether it's custom or computed - localStorage.setItem('theme-background-color', this.theme.containerBackground); - }); - - // Every time the user account data is updated from the server, consider resetting - // paid settings to the free defaults. This ensures that they're reset on - // logout & subscription expiration (even if that happened while the app was - // closed), but don't get reset when the app starts with stale account data. - observe(this.accountStore, 'accountDataLastUpdated', () => { - if (!this.accountStore.isPaidUser) { - this.setTheme('automatic'); - } - }); - - await hydrate({ - key: 'ui-store', - store: this - }); - - const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)"); - this._setPrefersDarkTheme(darkThemeMq.matches); - darkThemeMq.addEventListener('change', e => { - this._setPrefersDarkTheme(e.matches); - }); - - console.log('UI store initialized'); + constructor(private accountStore: AccountStore) {} + + readonly initialized = lazyObservablePromise(async () => { + await this.accountStore.initialized; + + autorun(() => { + // Any time the theme changes, update the HTML background to match + document.querySelector("html")!.style.backgroundColor = + this.theme.containerBackground; + + // Persist the background colour standalone, so we can easily access it + // from the index.html loading script, whether it's custom or computed + localStorage.setItem( + "theme-background-color", + this.theme.containerBackground + ); }); - @action.bound - setTheme(themeNameOrObject: Theme | ThemeName | 'automatic') { - if (typeof themeNameOrObject === 'string') { - this._themeName = themeNameOrObject; - this.customTheme = undefined; - } else { - this._themeName = 'custom'; - this.customTheme = themeNameOrObject; - } - } - - buildCustomTheme(themeFile: string) { - const themeData: Partial | undefined = tryParseJson(themeFile); - if (!themeData) throw new Error("Could not parse theme JSON"); - - if (!themeData.name) throw new Error('Theme must contain a `name` field'); - if ( - !themeData.extends || - Themes[themeData.extends as ThemeName] === undefined - ) { - throw new Error('Theme must contain an `extends` field with a built-in theme name (dark/light/high-contrast)'); - } - - const baseTheme = Themes[themeData.extends]; - return { - ...baseTheme, - ...themeData - } as Theme; - } - - @persist @observable - private _themeName: ThemeName | 'automatic' | 'custom' = 'automatic'; - - get themeName() { - return this._themeName; - } - - /** - * Stores if user prefers a dark color theme (for example when set in system settings). - * Used if automatic theme is enabled. - */ - @observable - private _prefersDarkTheme: boolean = false; - - @action.bound - private _setPrefersDarkTheme(value: boolean) { - this._prefersDarkTheme = value; - } - - @persist('object') @observable - private customTheme: Theme | undefined = undefined; - - @computed - get theme(): Theme { - switch(this.themeName) { - case 'automatic': - return {...Themes[this._prefersDarkTheme ? 'dark' : 'light']} - case 'custom': - return this.customTheme!; - default: - return Themes[this.themeName]; - } - } - - // Set briefly at the moment any card expansion is toggled, to trigger animation for the expansion as it's - // applied, not always (to avoid animating expanded cards when they're rendered e.g. when selecting a request). - @observable - private animatedExpansionCard: string | undefined; - - /** - * For both requests & responses, there are two different ways to look at them (=4 perspectives in total). It - * depends on the use case (mostly: are you collecting data, or exploring behaviours) but this field changes which - * format is shown in the right-hand UI pane. Note that the list view always still shows the original values. - */ - @observable - contentPerspective: ContentPerspective = 'transformed'; - - // Store the view details cards state here, so that they persist - // when moving away from the page or deselecting all traffic. - @observable - private readonly viewCardStates = { - 'api': { collapsed: true }, - - 'request': { collapsed: false }, - 'requestBody': { collapsed: false }, - 'requestTrailers': { collapsed: false }, - 'response': { collapsed: false }, - 'responseBody': { collapsed: false }, - 'responseTrailers': { collapsed: false }, - - 'webSocketMessages': { collapsed: false }, - 'webSocketClose': { collapsed: false }, - - 'rtcConnection': { collapsed: false }, - 'rtcSessionOffer': { collapsed: false }, - 'rtcSessionAnswer': { collapsed: false }, - - 'performance': { collapsed: true }, - 'export': { collapsed: true } - }; - - @observable - expandedViewCard: ExpandableViewCardKey | undefined; - - @computed - get viewCardProps() { - return _.mapValues(this.viewCardStates, (state, key) => ({ - key, - ariaLabel: `${_.startCase(key)} section`, - expanded: key === this.animatedExpansionCard - ? 'starting' as const - : key === this.expandedViewCard, - collapsed: state.collapsed && key !== this.expandedViewCard, - onCollapseToggled: this.toggleViewCardCollapsed.bind(this, key as ViewCardKey), - onExpandToggled: isExpandableViewCard(key) - ? this.toggleViewCardExpanded.bind(this, key) - : _.noop - })); - } + // Every time the user account data is updated from the server, consider resetting + // paid settings to the free defaults. This ensures that they're reset on + // logout & subscription expiration (even if that happened while the app was + // closed), but don't get reset when the app starts with stale account data. + observe(this.accountStore, "accountDataLastUpdated", () => { + if (!this.accountStore.isPaidUser) { + this.setTheme("automatic"); + } + }); - @action - toggleViewCardCollapsed(key: ViewCardKey) { - const cardState = this.viewCardStates[key]; - cardState.collapsed = !cardState.collapsed; - this.expandedViewCard = undefined; - } + await hydrate({ + key: "ui-store", + store: this, + }); - @action - private toggleViewCardExpanded(key: ExpandableViewCardKey) { - if (this.expandedViewCard === key) { - this.expandedViewCard = undefined; - } else if (isExpandableViewCard(key)) { - this.viewCardStates[key].collapsed = false; - this.expandedViewCard = key; - - // Briefly set animatedExpansionCard, to trigger animation for this expansion: - this.animatedExpansionCard = key; - requestAnimationFrame(action(() => { - this.animatedExpansionCard = undefined; - })); - } - } + const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)"); + this._setPrefersDarkTheme(darkThemeMq.matches); + darkThemeMq.addEventListener("change", (e) => { + this._setPrefersDarkTheme(e.matches); + }); - // Store the send details cards state here - @observable - private readonly sendCardStates = { - 'requestHeaders': { collapsed: false }, - 'requestBody': { collapsed: false }, - 'responseHeaders': { collapsed: false }, - 'responseBody': { collapsed: false } - }; - - @observable - expandedSendRequestCard: ExpandableSendRequestCardKey | undefined; - - @observable - expandedSentResponseCard: ExpandableSentResponseCardKey | undefined; - - @computed - get sendCardProps() { - return _.mapValues(this.sendCardStates, (state, key) => { - const expandedState = key === this.expandedSendRequestCard - || key === this.expandedSentResponseCard; - - return { - key, - ariaLabel: `${_.startCase(key)} section`, - expanded: expandedState, - collapsed: state.collapsed && !expandedState, - onCollapseToggled: this.toggleSendCardCollapsed.bind(this, key as SendCardKey), - onExpandToggled: isExpandableSendCard(key) - ? this.toggleSendCardExpanded.bind(this, key) - : _.noop - }; - }); + console.log("UI store initialized"); + }); + + @action.bound + setTheme(themeNameOrObject: Theme | ThemeName | "automatic") { + if (typeof themeNameOrObject === "string") { + this._themeName = themeNameOrObject; + this.customTheme = undefined; + } else { + this._themeName = "custom"; + this.customTheme = themeNameOrObject; } - - @action - toggleSendCardCollapsed(key: SendCardKey) { - const cardState = this.sendCardStates[key]; - cardState.collapsed = !cardState.collapsed; - - const siblingCards: SendCardKey[] = isSendRequestCard(key) - ? SEND_REQUEST_CARD_KEYS - : SENT_RESPONSE_CARD_KEYS; - - // If you collapse all cards, pop open an alternative, just because it looks a bit weird - // if you don't, and it makes it easier to quickly switch like an accordion in some cases. - if (siblingCards.every((k) => this.sendCardStates[k].collapsed)) { - const keyIndex = siblingCards.indexOf(key); - const bestAlternativeCard = (keyIndex === siblingCards.length - 1) - ? siblingCards[keyIndex - 1] // For last card, look back one - : siblingCards[keyIndex + 1] // Otherwise, look at next card - - this.toggleSendCardCollapsed(bestAlternativeCard); - } - - if (isSendRequestCard(key)) { - this.expandedSendRequestCard = undefined; - } else if (isSentResponseCard(key)) { - this.expandedSentResponseCard = undefined; - } else { - throw new UnreachableCheck(key); - } + } + + buildCustomTheme(themeFile: string) { + const themeData: Partial | undefined = tryParseJson(themeFile); + if (!themeData) throw new Error("Could not parse theme JSON"); + + if (!themeData.name) throw new Error("Theme must contain a `name` field"); + if ( + !themeData.extends || + Themes[themeData.extends as ThemeName] === undefined + ) { + throw new Error( + "Theme must contain an `extends` field with a built-in theme name (dark/light/high-contrast)" + ); } - @action - private toggleSendCardExpanded(key: ExpandableSendCardKey) { - const expandedCardField = isSendRequestCard(key) - ? 'expandedSendRequestCard' - : isSentResponseCard(key) - ? 'expandedSentResponseCard' - : unreachableCheck(key); - - if (this[expandedCardField] === key) { - this[expandedCardField] = undefined; - } else if (isExpandableSendCard(key)) { - this.sendCardStates[key].collapsed = false; - this[expandedCardField] = key as any; // We ensured key matches the field already above - - // We don't bother with animatedExpansionCard - not required for Send (we just - // animate top-line margin, not expanded card padding) - } + const baseTheme = Themes[themeData.extends]; + return { + ...baseTheme, + ...themeData, + } as Theme; + } + + @persist + @observable + private _themeName: ThemeName | "automatic" | "custom" = "automatic"; + + get themeName() { + return this._themeName; + } + + /** + * Stores if user prefers a dark color theme (for example when set in system settings). + * Used if automatic theme is enabled. + */ + @observable + private _prefersDarkTheme: boolean = false; + + @action.bound + private _setPrefersDarkTheme(value: boolean) { + this._prefersDarkTheme = value; + } + + @persist("object") + @observable + private customTheme: Theme | undefined = undefined; + + @computed + get theme(): Theme { + switch (this.themeName) { + case "automatic": + return { ...Themes[this._prefersDarkTheme ? "dark" : "light"] }; + case "custom": + return this.customTheme!; + default: + return Themes[this.themeName]; } - - @observable - private settingsCardStates = { - 'account': { collapsed: false }, - 'proxy': { collapsed: false }, - 'connection': { collapsed: false }, - 'api': { collapsed: false }, - 'themes': { collapsed: false } - }; - - @computed - get settingsCardProps() { - return _.mapValues(this.settingsCardStates, (state, key) => ({ - key, - ariaLabel: `${_.startCase(key)} section`, - collapsed: state.collapsed, - onCollapseToggled: this.toggleSettingsCardCollapsed.bind(this, key as SettingsCardKey) - })); + } + + // Set briefly at the moment any card expansion is toggled, to trigger animation for the expansion as it's + // applied, not always (to avoid animating expanded cards when they're rendered e.g. when selecting a request). + @observable + private animatedExpansionCard: string | undefined; + + /** + * For both requests & responses, there are two different ways to look at them (=4 perspectives in total). It + * depends on the use case (mostly: are you collecting data, or exploring behaviours) but this field changes which + * format is shown in the right-hand UI pane. Note that the list view always still shows the original values. + */ + @observable + contentPerspective: ContentPerspective = "transformed"; + + // Store the view details cards state here, so that they persist + // when moving away from the page or deselecting all traffic. + @observable + private readonly viewCardStates = { + api: { collapsed: true }, + + request: { collapsed: false }, + requestBody: { collapsed: false }, + requestTrailers: { collapsed: false }, + response: { collapsed: false }, + responseBody: { collapsed: false }, + responseTrailers: { collapsed: false }, + + webSocketMessages: { collapsed: false }, + webSocketClose: { collapsed: false }, + + rtcConnection: { collapsed: false }, + rtcSessionOffer: { collapsed: false }, + rtcSessionAnswer: { collapsed: false }, + + performance: { collapsed: true }, + export: { collapsed: true }, + }; + + @observable + expandedViewCard: ExpandableViewCardKey | undefined; + + // Store view list scroll position and selected entry to persist when switching tabs + @observable + viewScrollPosition: number = 0; + + @observable + selectedEventId: string | undefined; + + @computed + get viewCardProps() { + return _.mapValues(this.viewCardStates, (state, key) => ({ + key, + ariaLabel: `${_.startCase(key)} section`, + expanded: + key === this.animatedExpansionCard + ? ("starting" as const) + : key === this.expandedViewCard, + collapsed: state.collapsed && key !== this.expandedViewCard, + onCollapseToggled: this.toggleViewCardCollapsed.bind( + this, + key as ViewCardKey + ), + onExpandToggled: isExpandableViewCard(key) + ? this.toggleViewCardExpanded.bind(this, key) + : _.noop, + })); + } + + @action + toggleViewCardCollapsed(key: ViewCardKey) { + const cardState = this.viewCardStates[key]; + cardState.collapsed = !cardState.collapsed; + this.expandedViewCard = undefined; + } + + @action + private toggleViewCardExpanded(key: ExpandableViewCardKey) { + if (this.expandedViewCard === key) { + this.expandedViewCard = undefined; + } else if (isExpandableViewCard(key)) { + this.viewCardStates[key].collapsed = false; + this.expandedViewCard = key; + + // Briefly set animatedExpansionCard, to trigger animation for this expansion: + this.animatedExpansionCard = key; + requestAnimationFrame( + action(() => { + this.animatedExpansionCard = undefined; + }) + ); } - - @action - toggleSettingsCardCollapsed(key: SettingsCardKey) { - const cardState = this.settingsCardStates[key]; - cardState.collapsed = !cardState.collapsed; + } + + // Store the send details cards state here + @observable + private readonly sendCardStates = { + requestHeaders: { collapsed: false }, + requestBody: { collapsed: false }, + responseHeaders: { collapsed: false }, + responseBody: { collapsed: false }, + }; + + @observable + expandedSendRequestCard: ExpandableSendRequestCardKey | undefined; + + @observable + expandedSentResponseCard: ExpandableSentResponseCardKey | undefined; + + @computed + get sendCardProps() { + return _.mapValues(this.sendCardStates, (state, key) => { + const expandedState = + key === this.expandedSendRequestCard || + key === this.expandedSentResponseCard; + + return { + key, + ariaLabel: `${_.startCase(key)} section`, + expanded: expandedState, + collapsed: state.collapsed && !expandedState, + onCollapseToggled: this.toggleSendCardCollapsed.bind( + this, + key as SendCardKey + ), + onExpandToggled: isExpandableSendCard(key) + ? this.toggleSendCardExpanded.bind(this, key) + : _.noop, + }; + }); + } + + @action + toggleSendCardCollapsed(key: SendCardKey) { + const cardState = this.sendCardStates[key]; + cardState.collapsed = !cardState.collapsed; + + const siblingCards: SendCardKey[] = isSendRequestCard(key) + ? SEND_REQUEST_CARD_KEYS + : SENT_RESPONSE_CARD_KEYS; + + // If you collapse all cards, pop open an alternative, just because it looks a bit weird + // if you don't, and it makes it easier to quickly switch like an accordion in some cases. + if (siblingCards.every((k) => this.sendCardStates[k].collapsed)) { + const keyIndex = siblingCards.indexOf(key); + const bestAlternativeCard = + keyIndex === siblingCards.length - 1 + ? siblingCards[keyIndex - 1] // For last card, look back one + : siblingCards[keyIndex + 1]; // Otherwise, look at next card + + this.toggleSendCardCollapsed(bestAlternativeCard); } - @action.bound - rememberElectronPath(path: string) { - if (!this.previousElectronAppPaths.includes(path)) { - this.previousElectronAppPaths.unshift(path); - } - - // Keep only the most recent 3 electron paths used - this.previousElectronAppPaths = this.previousElectronAppPaths.slice(0, 3); + if (isSendRequestCard(key)) { + this.expandedSendRequestCard = undefined; + } else if (isSentResponseCard(key)) { + this.expandedSentResponseCard = undefined; + } else { + throw new UnreachableCheck(key); } - - @action.bound - forgetElectronPath(path: string) { - this.previousElectronAppPaths = this.previousElectronAppPaths.filter(p => p != path); + } + + @action + private toggleSendCardExpanded(key: ExpandableSendCardKey) { + const expandedCardField = isSendRequestCard(key) + ? "expandedSendRequestCard" + : isSentResponseCard(key) + ? "expandedSentResponseCard" + : unreachableCheck(key); + + if (this[expandedCardField] === key) { + this[expandedCardField] = undefined; + } else if (isExpandableSendCard(key)) { + this.sendCardStates[key].collapsed = false; + this[expandedCardField] = key as any; // We ensured key matches the field already above + + // We don't bother with animatedExpansionCard - not required for Send (we just + // animate top-line margin, not expanded card padding) } - - @persist('list') @observable - previousElectronAppPaths: string[] = []; - - @observable - activeFilterSet: FilterSet = emptyFilterSet(); - - @persist('object') @observable - _customFilters: { [name: string]: string } = {}; - - @computed - get customFilters() { - if (this.accountStore.isPaidUser) { - return this._customFilters; - } else { - return {}; - } + } + + @observable + private settingsCardStates = { + account: { collapsed: false }, + proxy: { collapsed: false }, + connection: { collapsed: false }, + api: { collapsed: false }, + themes: { collapsed: false }, + }; + + @computed + get settingsCardProps() { + return _.mapValues(this.settingsCardStates, (state, key) => ({ + key, + ariaLabel: `${_.startCase(key)} section`, + collapsed: state.collapsed, + onCollapseToggled: this.toggleSettingsCardCollapsed.bind( + this, + key as SettingsCardKey + ), + })); + } + + @action + toggleSettingsCardCollapsed(key: SettingsCardKey) { + const cardState = this.settingsCardStates[key]; + cardState.collapsed = !cardState.collapsed; + } + + @action.bound + rememberElectronPath(path: string) { + if (!this.previousElectronAppPaths.includes(path)) { + this.previousElectronAppPaths.unshift(path); } - @persist @observable - preferredShell: string | undefined = 'Bash'; - - @persist @observable - exportSnippetFormat: string | undefined; - - /** - * This tracks the context menu state *only if it's not handled natively*. This state - * is rendered by React as a fallback when DesktopApi.openContextMenu is not available. - */ - @observable.ref // This shouldn't be mutated - contextMenuState: ContextMenuState | undefined; - - handleContextMenuEvent( - event: React.MouseEvent, - items: readonly ContextMenuItem[], - data: T - ): void; - handleContextMenuEvent( - event: React.MouseEvent, - items: readonly ContextMenuItem[] - ): void; - @action.bound - handleContextMenuEvent( - event: React.MouseEvent, - items: readonly ContextMenuItem[], - data?: T - ): void { - if (!items.length) return; - - event.preventDefault(); - - if (DesktopApi.openContextMenu) { - const position = { x: event.pageX, y: event.pageY }; - this.contextMenuState = undefined; // Should be set already, but let's be explicit - - DesktopApi.openContextMenu({ - position, - items: buildNativeContextMenuItems(items) - }).then((result) => { - if (result) { - const selectedItem = _.get(items, result) as ContextMenuOption; - selectedItem.callback(data!); - } - }).catch((error) => { - console.log(error); - throw new Error('Error opening context menu'); - }); - } else { - event.persist(); - this.contextMenuState = { - data, - event, - items - }; - } + // Keep only the most recent 3 electron paths used + this.previousElectronAppPaths = this.previousElectronAppPaths.slice(0, 3); + } + + @action.bound + forgetElectronPath(path: string) { + this.previousElectronAppPaths = this.previousElectronAppPaths.filter( + (p) => p != path + ); + } + + @persist("list") + @observable + previousElectronAppPaths: string[] = []; + + @observable + activeFilterSet: FilterSet = emptyFilterSet(); + + @persist("object") + @observable + _customFilters: { [name: string]: string } = {}; + + @computed + get customFilters() { + if (this.accountStore.isPaidUser) { + return this._customFilters; + } else { + return {}; } - - @action.bound - clearHtmlContextMenu() { - this.contextMenuState = undefined; + } + + @persist + @observable + preferredShell: string | undefined = "Bash"; + + @persist + @observable + exportSnippetFormat: string | undefined; + + @observable + searchFilters: FilterSet = emptyFilterSet(); + + @action.bound + setSearchFiltersValue(value: FilterSet) { + this.searchFilters = value; + } + + // Actions for persisting view state when switching tabs + @action.bound + setViewScrollPosition(position: number) { + this.viewScrollPosition = position; + } + + @action.bound + setSelectedEventId(eventId: string | undefined) { + this.selectedEventId = eventId; + } + + @observable.ref // This shouldn't be mutated + contextMenuState: ContextMenuState | undefined; + + handleContextMenuEvent( + event: React.MouseEvent, + items: readonly ContextMenuItem[], + data: T + ): void; + handleContextMenuEvent( + event: React.MouseEvent, + items: readonly ContextMenuItem[] + ): void; + @action.bound + handleContextMenuEvent( + event: React.MouseEvent, + items: readonly ContextMenuItem[], + data?: T + ): void { + if (!items.length) return; + + event.preventDefault(); + + if (DesktopApi.openContextMenu) { + const position = { x: event.pageX, y: event.pageY }; + this.contextMenuState = undefined; // Should be set already, but let's be explicit + + DesktopApi.openContextMenu({ + position, + items: buildNativeContextMenuItems(items), + }) + .then((result) => { + if (result) { + const selectedItem = _.get(items, result) as ContextMenuOption; + selectedItem.callback(data!); + } + }) + .catch((error) => { + console.log(error); + throw new Error("Error opening context menu"); + }); + } else { + event.persist(); + this.contextMenuState = { + data, + event, + items, + }; } + } + @action.bound + clearHtmlContextMenu() { + this.contextMenuState = undefined; + } }