Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions packages/formbricks/lib/src/types/survey.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class TSurvey {
this.recontactDays,
this.displayPercentage,
this.segment,
this.interactionRefresh,
required Map<String, dynamic> raw,
}) : _raw = raw;

Expand Down Expand Up @@ -62,6 +63,11 @@ class TSurvey {
: TSurveySegment.fromJson(
(json['segment'] as Map).cast<String, dynamic>(),
),
interactionRefresh: json['interactionRefresh'] is Map
? TInteractionRefresh.fromJson(
(json['interactionRefresh'] as Map).cast<String, dynamic>(),
)
: null,
raw: json,
);

Expand Down Expand Up @@ -101,6 +107,10 @@ class TSurvey {
/// The segment targeting this survey, or null when untargeted.
final TSurveySegment? segment;

/// Whether interacting with this survey can change some live survey's segment
/// membership. Null unless the workspace uses survey-interaction targeting.
final TInteractionRefresh? interactionRefresh;

final Map<String, dynamic> _raw;

/// Whether the survey is available in more than one language.
Expand All @@ -110,6 +120,66 @@ class TSurvey {
Map<String, dynamic> toJson() => _raw;
}

/// The survey-lifecycle moments that can flip interaction-based segment
/// membership. Names match the source names used by the JS SDK.
enum InteractionSource {
/// A display was created — drives `have seen` / `have not seen`.
onDisplay,

/// A response was created — drives `have started responding to`.
onResponse,

/// The response was finished — drives `have completed` / `have not completed`.
onFinished,
}

/// Per-survey gate for the post-interaction segment refresh.
///
/// Each flag says whether interacting with *this* survey via that event can
/// change some live survey's segment membership — so a survey referenced only by
/// a "have seen" filter refreshes on display but not on response or finish, and
/// a survey no interaction filter points at never refreshes at all.
///
/// The client API attaches this only for workspaces that use survey-interaction
/// targeting, so it is absent for everyone else, and present-but-all-false for
/// surveys in such a workspace that no interaction filter references.
class TInteractionRefresh {
/// Creates a gate. Every flag defaults to "do not refresh".
const TInteractionRefresh({
this.onDisplay = false,
this.onResponse = false,
this.onFinished = false,
});

/// Builds a gate from decoded JSON.
///
/// Deliberately tolerant: a missing or non-boolean flag reads as `false`, so a
/// partial object from the server can never fail the workspace-state decode
/// and blank out every survey.
factory TInteractionRefresh.fromJson(Map<String, dynamic> json) =>
TInteractionRefresh(
onDisplay: json['onDisplay'] == true,
onResponse: json['onResponse'] == true,
onFinished: json['onFinished'] == true,
);

/// Whether a display can flip membership.
final bool onDisplay;

/// Whether a created response can flip membership.
final bool onResponse;

/// Whether finishing the survey can flip membership.
final bool onFinished;

/// Whether an interaction of this kind should trigger a user-state refresh.
bool shouldRefresh(InteractionSource source) => switch (source) {
InteractionSource.onDisplay => onDisplay,
InteractionSource.onResponse => onResponse,
InteractionSource.onFinished => onFinished,
};
}

/// The minimal segment shape read for targeting: `{ id, hasFilters }`.
/// Tolerates the legacy cached shape carrying a full `filters` array.
class TSurveySegment {
Expand Down
44 changes: 44 additions & 0 deletions packages/formbricks/lib/src/user/interaction_refresh.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/// Post-interaction segment refresh.
///
/// A `surveyInteraction` segment filter ("have seen X", "have completed X", ...)
/// can change who a contact is the moment they interact with a survey. Segment
/// membership is only ever computed by the backend, so the local bookkeeping in
/// the WebView host (displays / responses) is not enough — the user state has to
/// be refetched.
library;

import 'dart:async';

import '../common/logger.dart';
import '../types/survey.dart';
import 'update_queue.dart';

/// Pulls fresh server-computed `segments` after an interaction that can flip
/// segment membership, instead of waiting for the user state to expire.
///
/// The refresh is deliberately gated twice, because a `/user` sync is not cheap:
/// * no-op for anonymous users, who never receive segments in the first place;
/// * no-op unless the backend set the bit for this survey and this event.
///
/// It is routed through the [UpdateQueue] rather than sending directly, so a
/// display -> response -> finish burst coalesces into a single request.
void refreshSegmentsAfterInteraction(
String? userId,
TSurvey survey,
InteractionSource source,
) {
if (userId == null || userId.isEmpty) return;
if (!(survey.interactionRefresh?.shouldRefresh(source) ?? false)) return;

Logger.debug(
'Refreshing segments after ${source.name} on survey ${survey.id}',
);

final queue = UpdateQueue.instance;
queue.updateUserId(userId);
// `processUpdates` completes with an error when the flush throws, and this is
// fire-and-forget — `unawaited` marks the future as intentionally not awaited but
// does not handle its errors, so one would surface as an unhandled async error.
// The queue already logs the real cause, so swallow it rather than report twice.
unawaited(queue.processUpdates().catchError((Object _) {}));
}
8 changes: 8 additions & 0 deletions packages/formbricks/lib/src/widgets/survey_html.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ String buildSurveyHtml(SurveyHtmlOptions options) {
postFormbricksMessage({ onResponseCreated: true });
};

// Fires once the finished response has been accepted by the backend — the
// runtime gates this on isResponseSendingFinished, and passing
// getSetIsResponseSendingFinished below flips that initial state to false.
function onFinished() {
postFormbricksMessage({ onFinished: true });
};

function getSetIsResponseSendingFinished() { /* noop */ };
function getSetIsError() { /* noop */ };

Expand Down Expand Up @@ -204,6 +211,7 @@ String buildSurveyHtml(SurveyHtmlOptions options) {
...options,
onDisplayCreated,
onResponseCreated,
onFinished,
onClose,
getSetIsResponseSendingFinished,
getSetIsError,
Expand Down
24 changes: 24 additions & 0 deletions packages/formbricks/lib/src/widgets/survey_webview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import '../common/utils.dart';
import '../survey/survey_store.dart';
import '../types/config.dart';
import '../types/survey.dart';
import '../user/interaction_refresh.dart';
import 'default_webview_host.dart';
import 'survey_html.dart';
import 'webview_event.dart';
Expand Down Expand Up @@ -79,6 +80,9 @@ class _SurveyWebViewState extends State<SurveyWebView> {
// then close) can't clobber each other.
Future<void> _configOps = Future<void>.value();

// Interaction sources already refreshed during this showing.
final Set<InteractionSource> _refreshedSources = <InteractionSource>{};

FormbricksConfig get _config => widget.config ?? FormbricksConfig.instance;
SurveyStore get _store => widget.store ?? SurveyStore.instance;

Expand Down Expand Up @@ -253,8 +257,12 @@ class _SurveyWebViewState extends State<SurveyWebView> {
switch (event) {
case DisplayCreatedEvent():
_enqueueConfigOp(_recordDisplay);
_refreshSegmentsOnce(InteractionSource.onDisplay);
case ResponseCreatedEvent():
_enqueueConfigOp(_recordResponse);
_refreshSegmentsOnce(InteractionSource.onResponse);
case FinishedEvent():
_refreshSegmentsOnce(InteractionSource.onFinished);
case OpenExternalUrlEvent(:final url):
unawaited(openExternalUrl(url, launch: widget.launch));
case CloseEvent():
Expand All @@ -266,6 +274,22 @@ class _SurveyWebViewState extends State<SurveyWebView> {
}
}

/// Forwards an interaction to the segment refresh at most once per source.
///
/// One `State` lives per survey showing, so this is scoped to that showing.
/// The survey runtime guards `onResponseCreated` itself, but `onFinished` is
/// not guarded there, and a self-hosted server may serve an older bundle — so
/// the refresh is gated here too. Only the refresh is gated; the local
/// displays/responses bookkeeping keeps its existing behaviour.
void _refreshSegmentsOnce(InteractionSource source) {
if (!_refreshedSources.add(source)) return;
refreshSegmentsAfterInteraction(
_config.getOrNull()?.user.data.userId,
widget.survey,
source,
);
}

void _handleWebViewLoadError() {
if (!mounted) return;
Logger.error('Survey WebView failed to load. Closing survey.');
Expand Down
13 changes: 10 additions & 3 deletions packages/formbricks/lib/src/widgets/webview_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ final class ResponseCreatedEvent extends WebViewEvent {
const ResponseCreatedEvent();
}

/// The survey was completed and the finished response was accepted by the
/// backend (the runtime gates this on `isResponseSendingFinished`).
final class FinishedEvent extends WebViewEvent {
/// Creates a finished event.
const FinishedEvent();
}

/// The runtime asked to close the survey.
final class CloseEvent extends WebViewEvent {
/// Creates a close event.
Expand Down Expand Up @@ -74,9 +81,8 @@ final class GeometryEvent extends WebViewEvent {
/// - A `Console` payload maps to a single [ConsoleEvent] (mutually exclusive,
/// like the runtime handler).
/// - Otherwise one event is emitted per truthy flag, in handler order
/// (display, response, open-external-url, close). A well-formed but
/// non-actionable payload (e.g. `{onFinished:true}`) yields `const []`
/// quietly.
/// (display, response, finished, open-external-url, close). A well-formed but
/// non-actionable payload (e.g. `{}`) yields `const []` quietly.
List<WebViewEvent> parseWebViewEvents(String raw) {
final map = _decodeMessage(raw);
if (map == null) return const [];
Expand All @@ -98,6 +104,7 @@ List<WebViewEvent> parseWebViewEvents(String raw) {
if (map['onResponseCreated'] == true) {
events.add(const ResponseCreatedEvent());
}
if (map['onFinished'] == true) events.add(const FinishedEvent());
if (map['onOpenExternalURL'] == true) {
events.add(OpenExternalUrlEvent(_externalUrl(map)!));
}
Expand Down
Loading