Groups
List the community groups, keep the user's follow state in sync with your app, and send taps on locked groups to your own upsell screen.
Before you begin
- Complete the Quickstart: the SDK is initialized and the community UI opens.
- Create the groups in the back office. Mark the groups that require an entitlement as locked there: the backend decides access, not the app.
- Following and unfollowing act on the connected user. Guests are provisioned automatically, so the calls work before a user signs in.
How it works
A group is a content category that users follow. For the connected user, each group carries:
| Field | Meaning |
|---|---|
id | Stable group id. Use it to open the group or to change its follow state. |
name | Display name, in the user's language. |
isFollowed | true when the user follows the group. |
canChangeFollowStatus | false for a group the user cannot follow or unfollow (for example an essential group). |
canAccess | false for a locked group: it is listed, but the user cannot open it. |
canCreateChildren | false when the user cannot post in the group. |
The SDK keeps the group list as observable state and refreshes it after every follow change. Your app can change the follow state in two ways: one group at a time, or a batch of actions that the backend reconciles by date. Access to a locked group is never enforced by the app: the SDK calls your callback and lets you decide what to show.
1. List available groups and read their state
Observe the group list to render it in your app, and fetch it to force a refresh from the server.
- Android
- iOS
- Flutter
- React Native
- Unity
OctopusSDK.groups: Flow<List<OctopusGroup>>— the cached group list, updated on every change.OctopusSDK.fetchGroups()— asuspendfunction that returnsOctopusResult<List<OctopusGroup>, Nothing>.
// Observe the cached groups
OctopusSDK.groups.collect { groups ->
// groups: List<OctopusGroup>
// each has: id, name, isFollowed, canChangeFollowStatus, canAccess, canCreateChildren
}
// Fetch the latest groups from the server
when (val result = OctopusSDK.fetchGroups()) {
is OctopusResult.Success -> {
val groups = result.data // List<OctopusGroup>
}
is OctopusResult.Failure -> {
// No network, user not authenticated, server error…
}
}
Render a group whose canAccess is false as locked, and send its taps to your access-denied callback — see Gate access to locked groups.
octopus.$groups— the publisher of the@Publishedgroup list, updated on every change.octopus.fetchGroups()—async throws. It refreshes the list, which you then receive from$groups.
// Observe the cached groups
octopus.$groups
.sink { groups in
// groups: [OctopusGroup]
// each has: id, name, isFollowed, canChangeFollowStatus, canAccess, canCreateChildren
}
.store(in: &cancellables)
// Fetch the latest groups from the server
do {
try await octopus.fetchGroups()
} catch {
// No network, server error…
}
Render a group whose canAccess is false as locked, and send its taps to your access-denied callback — see Gate access to locked groups.
OctopusSDK.groups: Stream<List<OctopusGroup>>— a static stream of the cached group list, updated on every change.octopus.fetchGroups()— returnsFuture<OctopusResult<List<OctopusGroup>, OctopusServerError>>.
final octopus = OctopusSDK();
// Observe the cached groups
final subscription = OctopusSDK.groups.listen((groups) {
// groups: List<OctopusGroup>
// each has: id, name, isFollowed, canChangeFollowStatus, canAccess, canCreateChildren
});
// Fetch the latest groups from the server
final result = await octopus.fetchGroups();
result
..onSuccess((groups) {
// groups: List<OctopusGroup>
})
..onFailure((failure) {
// No network, user not authenticated, server error…
});
Render a group whose canAccess is false as locked, and send its taps to your access-denied callback — see Gate access to locked groups.
addGroupsListener(callback)— callscallbackwith the current list at once, then on every change. Returns a subscription; callremove()on it to stop.getGroups()— returns the cached list synchronously. It can be empty right afterinitialize.fetchGroups()— returnsPromise<OctopusGroup[]>with the latest list from the server.
import { addGroupsListener, fetchGroups, getGroups } from '@octopus-community/react-native';
// Observe the cached groups
const subscription = addGroupsListener((groups) => {
// groups: OctopusGroup[]
// each has: id, name, isFollowed, canChangeFollowStatus, canAccess, canCreateChildren
});
// Read the cached groups once
const cached = getGroups();
// Fetch the latest groups from the server
const groups = await fetchGroups();
// Later, to stop receiving updates:
subscription.remove();
Render a group whose canAccess is false as locked, and send its taps to your access-denied callback — see Gate access to locked groups.
OctopusSDK.OnGroupsChanged: event Action<IList<OctopusGroup>>— a continuous stream of the user's groups. It mirrors the native group list and fires whenever it changes, including after a fetch, a sync or a follow change made in the community UI.OctopusSDK.FetchGroups(onCompleted, onError)— fetches the latest list from the server.onErroris optional.
using System.Collections.Generic;
using UnityEngine;
// Observe the groups
System.Action<IList<OctopusGroup>> onGroups = groups =>
{
foreach (var group in groups)
{
// each has: Id, Name, IsFollowed, CanChangeFollowStatus, CanAccess, CanCreateChildren
}
};
OctopusSDK.OnGroupsChanged += onGroups;
// Fetch the latest groups from the server
OctopusSDK.FetchGroups(
onCompleted: groups => { /* groups: IList<OctopusGroup> */ },
onError: message => Debug.LogError(message));
// Later, to stop receiving updates:
OctopusSDK.OnGroupsChanged -= onGroups;
Render a group whose CanAccess is false as locked, and send its taps to your access-denied callback — see Gate access to locked groups.
2. Follow or unfollow one group
Android availableiOS not availableFlutter availableReact Native ≥ 1.13.0Unity ≥ 1.13.0
Follow or unfollow a single group when the user acts on it in your own screens. The group list updates when the call succeeds. To send several changes at once, or changes observed earlier, use Sync follow state in batch instead.
- Android
- iOS
- Flutter
- React Native
- Unity
groupId: String— the Octopus group id.- Returns
OctopusResult<Unit, GroupFollowUnfollowError>. The errors areMissingGroup,UnfollowableGroup,GroupAlreadyFollowed,GroupAlreadyUnfollowed,LastFollowedGroupandUnknown, each with anerrorMessage.
when (val result = OctopusSDK.followGroup(groupId = "GROUP_ID")) {
is OctopusResult.Success -> {
// The user now follows the group
}
is OctopusResult.Failure -> {
// Connection failure, or InvalidArguments carrying a GroupFollowUnfollowError
}
}
// To unfollow:
// OctopusSDK.unfollowGroup(groupId = "GROUP_ID")
unfollowGroup refuses to unfollow the user's last followed group and returns LastFollowedGroup.
Following or unfollowing a single group is not yet available on iOS. Send a one-action batch with syncFollowGroups.
groupId: String— the Octopus group id.- Returns
Future<OctopusResult<void, GroupFollowUnfollowError>>. The errors areGroupFollowUnfollowMissingGroupError,GroupFollowUnfollowUnfollowableGroupError,GroupFollowUnfollowGroupAlreadyFollowedError,GroupFollowUnfollowGroupAlreadyUnfollowedError,GroupFollowUnfollowLastFollowedGroupErrorandGroupFollowUnfollowUnknownError, each with anerrorMessage.
final octopus = OctopusSDK();
final result = await octopus.followGroup('GROUP_ID');
result
..onSuccess((_) {
// The user now follows the group
})
..onError((error) {
// error: GroupFollowUnfollowError
debugPrint(error.errorMessage);
});
// To unfollow:
// await octopus.unfollowGroup('GROUP_ID');
On Android, unfollowing the user's last followed group is refused with GroupFollowUnfollowLastFollowedGroupError. On iOS the call is sent as a one-action sync, which has no such guard.
groupId: string— the Octopus group id.- Returns
Promise<void>. It rejects with aGroupFollowUnfollowError; test it withisGroupFollowUnfollowErrorand readcodeandmessage.
import { followGroup, isGroupFollowUnfollowError } from '@octopus-community/react-native';
try {
await followGroup('GROUP_ID');
// The user now follows the group
} catch (error) {
if (isGroupFollowUnfollowError(error)) {
console.warn(`followGroup failed — ${error.code}: ${error.message}`);
}
}
// To unfollow:
// await unfollowGroup('GROUP_ID');
On Android, unfollowing the user's last followed group is refused with LAST_FOLLOWED_GROUP. On iOS the call is sent as a one-action sync, which has no such guard.
groupId: string— the Octopus group id.onSuccess: Action— called when the follow state is updated.onError: Action<OctopusGroupFollowUnfollowError>— readCodeandMessage.
OctopusSDK.FollowGroup(
"GROUP_ID",
onSuccess: () => { /* The user now follows the group */ },
onError: error => Debug.LogWarning($"{error.Code}: {error.Message}"));
// To unfollow:
// OctopusSDK.UnfollowGroup("GROUP_ID", onSuccess, onError);
Callbacks run on the Unity main thread. On Android, unfollowing the user's last followed group is refused with LastFollowedGroup. On iOS the call is sent as a one-action sync: an applied or skipped action counts as a success, and there is no last-group guard.
3. Sync follow state in batch
Push a batch of follow and unfollow actions for the connected user, each with a timestamp. The backend compares each action with the last recorded action on that group, whether it came from an earlier sync or from the user in the community UI. The action with the most recent actionDate wins; an action whose actionDate is older than or equal to the stored one is skipped. Essential groups always stay followed: an unfollow on them returns notUnfollowable.
Each action carries:
groupId— the id of the group to follow or unfollow.followed—trueto follow,falseto unfollow.actionDate— when your app observed the action. The backend compares it with the last recorded action for this user and group.
The call returns one result per action, with the groupId and a status: applied, skipped, alreadyFollowed, alreadyUnfollowed, groupNotFound, notFollowable, notUnfollowable or unknownError. Match results to actions by groupId, not by position. An empty list completes at once with no network call.
- Android
- iOS
- Flutter
- React Native
- Unity
actions: List<SyncFollowGroupAction>—SyncFollowGroupAction(groupId, followed, actionDate: Date).- Returns
OctopusResult<List<SyncFollowGroupResult>, Nothing>.
val actions = listOf(
SyncFollowGroupAction(groupId = "g1", followed = true, actionDate = Date()),
SyncFollowGroupAction(groupId = "g2", followed = false, actionDate = Date()),
)
when (val result = OctopusSDK.syncFollowGroups(actions = actions)) {
is OctopusResult.Success -> {
for (r in result.data) {
// r.groupId — the group this result refers to
when (r.status) {
is SyncFollowGroupStatus.Applied -> {} // action was applied
is SyncFollowGroupStatus.Skipped -> {} // user acted more recently — client action ignored
is SyncFollowGroupStatus.AlreadyFollowed -> {} // user already follows — no change
is SyncFollowGroupStatus.AlreadyUnfollowed -> {} // user already does not follow — no change
is SyncFollowGroupStatus.GroupNotFound -> {} // no group with that id
is SyncFollowGroupStatus.NotFollowable -> {} // group is admin-restricted
is SyncFollowGroupStatus.NotUnfollowable -> {} // essential group
is SyncFollowGroupStatus.UnknownError -> {} // unclassified server error for this action
}
}
}
is OctopusResult.Failure -> {
// Transport-level failure: no network, user not authenticated, server error…
}
}
actions: [OctopusSyncFollowGroup.Action]—.init(groupId:followed:actionDate:).async throws(OctopusSyncFollowGroup.Error), returns[OctopusSyncFollowGroup.Result].
let actions: [OctopusSyncFollowGroup.Action] = [
.init(groupId: "g1", followed: true, actionDate: .now),
.init(groupId: "g2", followed: false, actionDate: .now),
]
do {
let results = try await octopus.syncFollowGroups(actions: actions)
for r in results {
// r.groupId — the group this result refers to
switch r.status {
case .applied: break // action was applied
case .skipped: break // user acted more recently — client action ignored
case .alreadyFollowed: break // user already follows — no change
case .alreadyUnfollowed: break // user already does not follow — no change
case .groupNotFound: break // no group with that id
case .notFollowable: break // group is admin-restricted
case .notUnfollowable: break // essential group
case .unknownError: break // unclassified server error for this action
}
}
} catch {
// OctopusSyncFollowGroup.Error:
// .notConnected — no user context yet
// .noNetwork — device is offline
// .server(_) — server error (carries the underlying error)
// .other(_) — any other error
print(error.debugDescription)
}
actions: List<SyncFollowGroupAction>—SyncFollowGroupAction(groupId:, followed:, actionDate: DateTime).- Returns
Future<List<SyncFollowGroupResult>>. A transport-level failure throws aPlatformException.
import 'package:flutter/services.dart';
import 'package:octopus_sdk_flutter/octopus_sdk_flutter.dart';
final octopus = OctopusSDK();
final actions = [
SyncFollowGroupAction(groupId: 'g1', followed: true, actionDate: DateTime.now()),
SyncFollowGroupAction(groupId: 'g2', followed: false, actionDate: DateTime.now()),
];
try {
final results = await octopus.syncFollowGroups(actions);
for (final r in results) {
// r.groupId — the group this result refers to
switch (r.status) {
case SyncFollowGroupStatus.applied: break; // action was applied
case SyncFollowGroupStatus.skipped: break; // user acted more recently — client action ignored
case SyncFollowGroupStatus.alreadyFollowed: break; // user already follows — no change
case SyncFollowGroupStatus.alreadyUnfollowed: break; // user already does not follow — no change
case SyncFollowGroupStatus.groupNotFound: break; // no group with that id
case SyncFollowGroupStatus.notFollowable: break; // group is admin-restricted
case SyncFollowGroupStatus.notUnfollowable: break; // essential group
case SyncFollowGroupStatus.unknownError: break; // unclassified server error for this action
}
}
} on PlatformException catch (e) {
// e.code: 'not_connected', 'no_network', 'server', 'other'
}
actions: SyncFollowGroupAction[]—{ groupId, followed, actionDate: Date }.- Returns
Promise<SyncFollowGroupResult[]>. A transport-level failure rejects with a native error code.
import { syncFollowGroups, SyncFollowGroupStatus } from '@octopus-community/react-native';
import type { SyncFollowGroupAction, SyncFollowGroupResult } from '@octopus-community/react-native';
const actions: SyncFollowGroupAction[] = [
{ groupId: 'g1', followed: true, actionDate: new Date() },
{ groupId: 'g2', followed: false, actionDate: new Date() },
];
try {
const results: SyncFollowGroupResult[] = await syncFollowGroups(actions);
for (const r of results) {
// r.groupId — the group this result refers to
switch (r.status) {
case SyncFollowGroupStatus.Applied: break; // action was applied
case SyncFollowGroupStatus.Skipped: break; // user acted more recently — client action ignored
case SyncFollowGroupStatus.AlreadyFollowed: break; // user already follows — no change
case SyncFollowGroupStatus.AlreadyUnfollowed: break; // user already does not follow — no change
case SyncFollowGroupStatus.GroupNotFound: break; // no group with that id
case SyncFollowGroupStatus.NotFollowable: break; // group is admin-restricted
case SyncFollowGroupStatus.NotUnfollowable: break; // essential group
case SyncFollowGroupStatus.UnknownError: break; // unclassified server error for this action
}
}
} catch (error: any) {
// error.code: 'not_connected', 'no_network', 'server', 'other'
console.error(error.code, error.message);
}
actions: IList<OctopusSyncFollowGroupAction>—{ GroupId, Followed, ActionDate }.onCompleted: Action<IList<OctopusSyncFollowGroupResult>>— one result per action.onError: Action<string>— optional, called on a transport-level failure.
using System;
using System.Collections.Generic;
using UnityEngine;
var actions = new List<OctopusSyncFollowGroupAction>
{
new OctopusSyncFollowGroupAction { GroupId = "g1", Followed = true, ActionDate = DateTime.UtcNow },
new OctopusSyncFollowGroupAction { GroupId = "g2", Followed = false, ActionDate = DateTime.UtcNow },
};
OctopusSDK.SyncFollowGroups(
actions,
onCompleted: results =>
{
foreach (var r in results)
{
// r.GroupId — the group this result refers to
switch (r.Status)
{
case OctopusSyncFollowGroupStatus.Applied: break; // action was applied
case OctopusSyncFollowGroupStatus.Skipped: break; // user acted more recently — client action ignored
case OctopusSyncFollowGroupStatus.AlreadyFollowed: break; // user already follows — no change
case OctopusSyncFollowGroupStatus.AlreadyUnfollowed: break; // user already does not follow — no change
case OctopusSyncFollowGroupStatus.GroupNotFound: break; // no group with that id
case OctopusSyncFollowGroupStatus.NotFollowable: break; // group is admin-restricted
case OctopusSyncFollowGroupStatus.NotUnfollowable: break; // essential group
case OctopusSyncFollowGroupStatus.UnknownError: break; // unclassified server error for this action
}
}
},
onError: message => Debug.LogError(message));
How actionDate resolves conflicts
Starting from a group g1 with no recorded state, the action with the most recent actionDate wins, whatever the order in which the backend receives the actions:
| # | Source | Action | actionDate | Result | Follow state after |
|---|---|---|---|---|---|
| 1 | client sync | follow | Jan 10 | Applied | followed (Jan 10) |
| 2 | user, in the app | unfollow | Jan 12 (now) | Applied | not followed (Jan 12) |
| 3 | client sync | follow | Jan 11 | Skipped | not followed (Jan 12) |
A manual action in the community UI carries the current time as its actionDate, so it wins over older client syncs. Action #3 is skipped because its actionDate (Jan 11) is older than the last recorded one (Jan 12). Only actionDate matters, not the order of arrival.
actionDate is not a scheduler
A future actionDate is applied at once, and it becomes the baseline that later actions must beat. It does not defer the action to that date:
| # | Source | Action | actionDate | Result | Effect |
|---|---|---|---|---|---|
| 1 | client sync | follow | Mar 31 (future) | Applied | user follows g2 now; baseline set to Mar 31 |
| 2 | user, in the app | unfollow | Jan 12 (now) | Skipped | Jan 12 ≤ Mar 31 → the user cannot unfollow until Mar 31 |
Do not use a future actionDate to schedule a change. The action takes effect at once and then locks the group against any earlier-dated action, including the user's own, until that date. To apply a change later, keep the timing in your app and send the action with the current time when it should take effect.
4. Gate access to locked groups
A group can require an entitlement before it opens (for example a premium group). A locked group stays in the list and in the community UI, but canAccess is false and tapping it does not open it: the SDK calls your callback with the group id, so your app can show an upsell screen or a paywall. The backend resolves access; the SDK never enforces it on the device and never navigates for you.
The callback fires when the user:
- taps the group in the group list,
- taps the follow button of a locked group,
- picks a locked group in the create-post group picker,
- acts on a group screen after access was revoked during the session (for example when an entitlement expired).
Read canAccess on a group
Render locked groups differently in your own screens, from the server-resolved fields. Do not compute access from the user's entitlements in the app.
- Android
- iOS
- Flutter
- React Native
- Unity
OctopusSDK.groups.collect { groups ->
groups.forEach { group ->
if (group.canAccess) {
// Render normally
} else {
// Render as locked (for example with a lock icon). Taps go through the callback below.
}
}
}
octopus.$groups
.sink { groups in
for group in groups {
if group.canAccess {
// Render normally
} else {
// Render as locked (for example with a lock icon). Taps go through the callback below.
}
}
}
.store(in: &cancellables)
OctopusSDK.groups.listen((groups) {
for (final group in groups) {
if (group.canAccess) {
// Render normally
} else {
// Render as locked (for example with a lock icon). Taps go through the callback below.
}
}
});
import { addGroupsListener } from '@octopus-community/react-native';
const subscription = addGroupsListener((groups) => {
for (const group of groups) {
if (group.canAccess) {
// Render normally
} else {
// Render as locked (for example with a lock icon). Taps go through the callback below.
}
}
});
OctopusSDK.OnGroupsChanged += groups =>
{
foreach (var group in groups)
{
if (group.CanAccess)
{
// Render normally
}
else
{
// Render as locked (for example with a lock icon). Taps go through the callback below.
}
}
};
Handle the locked-group callback
Register one callback. It receives the id of the locked group the user tried to open.
- Android
- iOS
- Flutter
- React Native
- Unity
OctopusSDK.setGroupAccessDeniedCallback(callback: (groupId: String) -> Unit)— applies to every Octopus screen.onGroupAccessDeniedonoctopusComposables(...)— applies to the navigation graph you register, and takes precedence when both are set.
// SDK-level callback
OctopusSDK.setGroupAccessDeniedCallback { groupId ->
// Open your upsell flow, paywall, etc.
}
// Or per navigation graph
octopusComposables(
navController = navController,
onGroupAccessDenied = { groupId ->
// Open your upsell flow, paywall, etc.
},
// … other callbacks
)
Without a callback, taps on a locked group do nothing in the community UI. Register one so the user gets a next step.
octopus.set(groupAccessDeniedCallback:)— takes a closure that receives the group id.
octopus.set(groupAccessDeniedCallback: { groupId in
// Open your upsell flow, paywall, etc.
})
Without a callback, taps on a locked group do nothing in the community UI. Register one so the user gets a next step.
OctopusSDK.setGroupAccessDeniedCallback(callback)— returns a function that unregisters the callback. Registering again replaces the previous callback.
final unregister = OctopusSDK.setGroupAccessDeniedCallback((groupId) {
// Open your upsell flow, paywall, etc.
});
// Later, for example in dispose():
unregister();
Without a callback, taps on a locked group do nothing in the community UI. Register one so the user gets a next step.
setGroupAccessDeniedCallback(callback)— returns a function that unregisters the callback. Calling it again replaces the previous callback.
import { useEffect } from 'react';
import { setGroupAccessDeniedCallback } from '@octopus-community/react-native';
useEffect(() => {
return setGroupAccessDeniedCallback((groupId) => {
// Open your upsell flow, paywall, etc.
});
}, []);
Without a callback, taps on a locked group do nothing in the community UI. Register one so the user gets a next step.
OctopusSDK.OnGroupAccessDenied: event Action<string>— receives the group id on the Unity main thread.
System.Action<string> onAccessDenied = groupId =>
{
// Open your upsell flow, paywall, etc.
};
OctopusSDK.OnGroupAccessDenied += onAccessDenied;
// Later, to stop receiving callbacks:
OctopusSDK.OnGroupAccessDenied -= onAccessDenied;
Without a handler, taps on a locked group do nothing in the community UI. Delivery waits while the native UI pauses the player loop.
Behavior and limits
- The group list refreshes after every follow change, whether it comes from your app or from the user in the community UI.
actionDateis a precedence timestamp, not a schedule: every applied action takes effect at once.- Essential groups cannot be unfollowed.
canChangeFollowStatusisfalsefor them. - Access to a locked group is decided by the backend. Changing an entitlement in your app does not unlock a group until the backend resolves it.
- The SDK never navigates when access is denied: your callback decides what the user sees.
Next steps
- Events: react when the user follows or unfollows a group.
- Analytics: listen to all community events.
- Unity Editor mock mode: drive group changes in the Editor.