Skip to content
Draft
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
13 changes: 13 additions & 0 deletions .changeset/knockprovider-enabled-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@knocklabs/client": minor
---

Make the client do nothing (instead of throwing or making requests) when there's no signed-in user, and add tools to manage sign-in state.

- New `Knock.logout()` clears the user and disconnects everything: the websocket, the token-refresh timer, and the page-visibility listener.
- New `knock.authStatus` (`"authenticated"` or `"unauthenticated"`) and a subscribable `knock.authStore` to check or react to whether a user is signed in.
- With no signed-in user, these now do nothing instead of throwing or calling the API:
- Feed `markAs*` / `markAll*` / `fetchNextPage` (they also skip the optimistic UI update).
- Guides `fetch` / `subscribe` and the step actions. These previously threw, which could crash the app when Guides rendered before a user was set.
- Slack/MS Teams `authCheck` (returns "not connected"), `getChannels` / `getTeams` (return empty), and `messages.batchUpdateStatuses` (returns `[]`).
- Fixes two Guide bugs: real-time updates broke after a re-login (a stale socket reference), and the `history` patch used for location tracking broke when a Guide provider remounted.
21 changes: 21 additions & 0 deletions .changeset/knockprovider-enabled-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@knocklabs/react-core": minor
"@knocklabs/react": minor
"@knocklabs/react-native": minor
"@knocklabs/expo": minor
---

Add an `enabled` prop to `KnockProvider` (and an `enabled` option to `useAuthenticatedKnockClient`).

When `enabled` is `false`, the provider still renders its children but the Knock client sits idle: no identify call, no API requests, no websocket. Set it to `true` and it connects like a login; set it back to `false` and it disconnects and clears its data like a logout. It defaults to `true`, so existing code is unaffected.

Use this instead of conditionally mounting `KnockProvider`, for example to wait for a user token that loads asynchronously:

```tsx
<KnockProvider apiKey={apiKey} user={{ id: userId }} userToken={userToken} enabled={Boolean(userId && userToken)} />
```

Also fixed:

- `useFeedSettings` no longer calls `GET /v1/users/undefined/feeds/.../settings` when there's no user.
- `KnockProvider` now disconnects its client (websocket, token-refresh timer, listener) when it unmounts, instead of leaving them running.
12 changes: 12 additions & 0 deletions .changeset/use-knock-auth-state-reactive-integrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@knocklabs/react-core": minor
"@knocklabs/react": minor
"@knocklabs/react-native": minor
"@knocklabs/expo": minor
---

Add `useKnockAuthState()` and make Slack, MS Teams, and Expo respond to sign-in changes.

- New `useKnockAuthState(knock)` hook re-renders when the user signs in, signs out, or switches.
- Slack and MS Teams connection status now re-checks when the user changes, instead of checking once and sticking with that result.
- Expo waits for a signed-in user before registering for push notifications, so logged-out users don't see the OS permission prompt. A notification tapped while logged out no longer tries to update its status.
20 changes: 20 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ knockClient.authenticate(
);
```

### Logging out and authentication state

While no user is signed in, the client stays idle: user-scoped calls (feed
fetches, mark-as-read, Guides, Slack/Teams checks, and so on) do nothing instead
of throwing or making requests, and no websocket is opened. This makes it safe
to construct a client before you have a user.

Call `logout()` to clear the current user and tear down all stateful
connections (the socket, the token-expiration timer, and the page-visibility
listener):

```typescript
knockClient.logout();
```

You can observe authentication state via `knockClient.authStatus`
(`"authenticated" | "unauthenticated"`) or subscribe to the `knockClient.authStore`
for changes. In React, prefer the `enabled` prop on `KnockProvider` (see
`@knocklabs/react`), which manages this lifecycle for you.

### Retrieving new items from the feed

```typescript
Expand Down
40 changes: 40 additions & 0 deletions packages/client/src/clients/feed/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,25 @@ class Feed {
return this.store.getState();
}

/**
* Returns `true` when a user is signed in. When not, logs and returns `false`
* so mutation methods can skip early, without touching the store (no
* optimistic update) or the network. This matters for actions that fire
* automatically, like the popover's `markAllAsSeen` on open or a cell's
* `markAsInteracted` on click.
*/
private canMutate(operation: string): boolean {
if (this.knock.isAuthenticated()) {
return true;
}

this.knock.log(`[Feed] User is not authenticated, skipping ${operation}`);
return false;
}

async markAsSeen(itemOrItems: FeedItemOrItems) {
if (!this.canMutate("markAsSeen")) return;

const now = new Date().toISOString();
this.optimisticallyPerformStatusUpdate(
itemOrItems,
Expand All @@ -177,6 +195,8 @@ class Feed {
}

async markAllAsSeen() {
if (!this.canMutate("markAllAsSeen")) return;

// To mark all of the messages as seen we:
// 1. Optimistically update *everything* we have in the store
// 2. We decrement the `unseen_count` to zero optimistically
Expand Down Expand Up @@ -219,6 +239,8 @@ class Feed {
}

async markAsUnseen(itemOrItems: FeedItemOrItems) {
if (!this.canMutate("markAsUnseen")) return;

this.optimisticallyPerformStatusUpdate(
itemOrItems,
"unseen",
Expand All @@ -230,6 +252,8 @@ class Feed {
}

async markAsRead(itemOrItems: FeedItemOrItems) {
if (!this.canMutate("markAsRead")) return;

const now = new Date().toISOString();
this.optimisticallyPerformStatusUpdate(
itemOrItems,
Expand All @@ -242,6 +266,8 @@ class Feed {
}

async markAllAsRead() {
if (!this.canMutate("markAllAsRead")) return;

// To mark all of the messages as read we:
// 1. Optimistically update *everything* we have in the store
// 2. We decrement the `unread_count` to zero optimistically
Expand Down Expand Up @@ -284,6 +310,8 @@ class Feed {
}

async markAsUnread(itemOrItems: FeedItemOrItems) {
if (!this.canMutate("markAsUnread")) return;

this.optimisticallyPerformStatusUpdate(
itemOrItems,
"unread",
Expand All @@ -298,6 +326,8 @@ class Feed {
itemOrItems: FeedItemOrItems,
metadata?: Record<string, string>,
) {
if (!this.canMutate("markAsInteracted")) return;

const now = new Date().toISOString();
this.optimisticallyPerformStatusUpdate(
itemOrItems,
Expand All @@ -321,6 +351,8 @@ class Feed {
TODO: how do we handle rollbacks?
*/
async markAsArchived(itemOrItems: FeedItemOrItems) {
if (!this.canMutate("markAsArchived")) return;

const state = this.store.getState();

const shouldOptimisticallyRemoveItems =
Expand Down Expand Up @@ -392,6 +424,8 @@ class Feed {
}

async markAllAsArchived() {
if (!this.canMutate("markAllAsArchived")) return;

// Note: there is the potential for a race condition here because the bulk
// update is an async method, so if a new message comes in during this window before
// the update has been processed we'll effectively reset the `unseen_count` to be what it was.
Expand Down Expand Up @@ -419,6 +453,8 @@ class Feed {
}

async markAllReadAsArchived() {
if (!this.canMutate("markAllReadAsArchived")) return;

// Note: there is the potential for a race condition here because the bulk
// update is an async method, so if a new message comes in during this window before
// the update has been processed we'll effectively reset the `unseen_count` to be what it was.
Expand Down Expand Up @@ -461,6 +497,8 @@ class Feed {
}

async markAsUnarchived(itemOrItems: FeedItemOrItems) {
if (!this.canMutate("markAsUnarchived")) return;

const state = this.store.getState();

const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems];
Expand Down Expand Up @@ -595,6 +633,8 @@ class Feed {
}

async fetchNextPage(options: FetchFeedOptions = {}) {
if (!this.canMutate("fetchNextPage")) return;

// Attempts to fetch the next page of results (if we have any)
const { pageInfo } = this.store.getState();

Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/clients/feed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ class FeedClient {
this.feedInstances = this.feedInstances.filter((f) => f !== feed);
}

/** Whether this client currently manages any feed instances. */
hasInstances() {
return this.feedInstances.length > 0;
}

teardownInstances() {
for (const feed of this.feedInstances) {
feed.teardown();
Expand Down
Loading
Loading