Files
vercel__chat/packages/chat/src/callback-url.ts
Mohammed Mansoor Ahmed 4a0b5c0c3f feat(cards): add button tooltips and a card width hint (#895)
Buttons can now show hover text, and a card can ask to be rendered wider
than usual. Both are small hints: Teams renders them, and every other
adapter leaves the card exactly as it was before.

### Button tooltips

`Button` and `LinkButton` take an optional `tooltip`. On Teams it
appears when someone hovers over the button.

```tsx
<Card title="Deploy request">
  <Actions>
    <Button id="approve" style="primary" tooltip="Ships this build to production">
      Approve
    </Button>
    <LinkButton url="https://example.com/build/1234" tooltip="Opens the build log in your browser">
      View build
    </LinkButton>
  </Actions>
</Card>
```

The same option is available on the plain builder functions:

```ts
Button({ id: "approve", label: "Approve", tooltip: "Ships this build to production" })
```

Tooltips survive the `callbackUrl` flow too. When a button's callback
URL is swapped for a token before the card is sent, every other field on
the button is kept, so a tooltip on a callback button shows up just like
one on a regular button.

### Full-width cards

`Card` takes an optional `width`, either `"default"` or `"full"`. Teams
draws a `"full"` card wider than its usual size, which suits tables and
digests. It does not stretch the card across the whole chat pane, that
is how Teams defines full width.

```tsx
<Card title="Weekly digest" width="full">
  <Table headers={["Service", "Uptime"]} rows={[["api", "99.98%"], ["web", "99.95%"]]} />
</Card>
```

### What Teams receives

- `tooltip` becomes the `tooltip` on the Adaptive Card action, for both
submit and open-URL buttons.
- `width="full"` becomes `msteams: { width: "full" }` on the card.
- The card now declares Adaptive Card version 1.5, which is the version
that introduced action tooltips. Teams accepts cards up to 1.6 for bots,
so nothing changes for existing cards beyond the version number.
- The runtime-free `@chat-adapter/teams/cards` helpers understand both
new fields as well, so apps that build Teams cards without the full
adapter get the same result.

### Why only Teams

Slack and Google Chat have no hover text for buttons. They do have
screen-reader labels, but those replace the button text for assistive
technology rather than adding to it, so mapping a tooltip onto them
would change what a screen reader announces. The fields are documented
as Teams-only for that reason.

### Small cleanup along the way

The JSX runtime used to decide whether a set of props belonged to a
`Card` by checking for a fixed list of prop names. Any new `Card` prop
that was not on that list was silently dropped. Since `Card` is the only
component left once every other one has been matched, the props are now
used directly and the list is gone.

Docs for both props are on the cards page and in the API reference.

---------

Signed-off-by: Mohammed Mansoor Ahmed <mansoorahmed.mohammed@gmail.com>
Signed-off-by: Ben Sabic <bensabic@users.noreply.github.com>
Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
2026-09-04 22:08:15 +10:00

207 lines
5.1 KiB
TypeScript

import type {
ActionsElement,
ButtonElement,
CardChild,
CardElement,
} from "./cards";
import type { StateAdapter } from "./types";
const CALLBACK_TOKEN_PREFIX = "__cb:";
const CALLBACK_CACHE_KEY_PREFIX = "chat:callback:";
const CALLBACK_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const CALLBACK_LOCK_TTL_MS = 10_000;
interface StoredCallback {
actionId: string;
originalValue?: string;
scope: CallbackScope;
url: string;
}
interface CallbackContext {
actionId: string;
channelId?: string;
threadId?: string;
}
interface CallbackScope {
id: string;
type: "channel" | "thread";
}
export function encodeCallbackValue(token: string): string {
return `${CALLBACK_TOKEN_PREFIX}${token}`;
}
export function decodeCallbackValue(value: string | undefined): {
callbackToken: string | undefined;
} {
if (!value?.startsWith(CALLBACK_TOKEN_PREFIX)) {
return { callbackToken: undefined };
}
return { callbackToken: value.slice(CALLBACK_TOKEN_PREFIX.length) };
}
function generateToken(): string {
return crypto.randomUUID().replace(/-/g, "").slice(0, 16);
}
async function processActionsElement(
actions: ActionsElement,
stateAdapter: StateAdapter,
scope: CallbackScope
): Promise<ActionsElement> {
return {
type: "actions",
children: await Promise.all(
actions.children.map(async (el) => {
if (el.type !== "button" || !el.callbackUrl) {
return el;
}
const token = generateToken();
const stored: StoredCallback = {
actionId: el.id,
url: el.callbackUrl,
originalValue: el.value,
scope,
};
await stateAdapter.set(
`${CALLBACK_CACHE_KEY_PREFIX}${token}`,
stored,
CALLBACK_TTL_MS
);
// Keep every other button field so new ones (like tooltip) are not
// silently dropped; only the callback URL is replaced by the token.
const { callbackUrl: _callbackUrl, ...rest } = el;
const processed: ButtonElement = {
...rest,
value: encodeCallbackValue(token),
};
return processed;
})
),
};
}
function hasCallbackButtons(children: CardChild[]): boolean {
for (const child of children) {
if (child.type === "actions") {
for (const el of child.children) {
if (el.type === "button" && el.callbackUrl) {
return true;
}
}
}
if (
child.type === "section" &&
"children" in child &&
hasCallbackButtons(child.children)
) {
return true;
}
}
return false;
}
async function processChildren(
children: CardChild[],
stateAdapter: StateAdapter,
scope: CallbackScope
): Promise<CardChild[]> {
const result: CardChild[] = [];
for (const child of children) {
if (child.type === "actions") {
result.push(await processActionsElement(child, stateAdapter, scope));
} else if (child.type === "section" && "children" in child) {
result.push({
...child,
children: await processChildren(child.children, stateAdapter, scope),
});
} else {
result.push(child);
}
}
return result;
}
export async function processCardCallbackUrls(
card: CardElement,
stateAdapter: StateAdapter,
scope: CallbackScope
): Promise<CardElement> {
if (!hasCallbackButtons(card.children)) {
return card;
}
return {
...card,
children: await processChildren(card.children, stateAdapter, scope),
};
}
export async function resolveCallbackUrl(
token: string,
stateAdapter: StateAdapter,
context?: CallbackContext
): Promise<StoredCallback | null> {
const key = `${CALLBACK_CACHE_KEY_PREFIX}${token}`;
const lock = await stateAdapter.acquireLock(key, CALLBACK_LOCK_TTL_MS);
if (!lock) {
return null;
}
try {
const stored = await stateAdapter.get<StoredCallback>(key);
if (
!stored ||
typeof stored !== "object" ||
typeof stored.actionId !== "string" ||
typeof stored.url !== "string" ||
(stored.originalValue !== undefined &&
typeof stored.originalValue !== "string") ||
!stored.scope ||
typeof stored.scope.id !== "string" ||
(stored.scope.type !== "channel" && stored.scope.type !== "thread")
) {
return null;
}
const scopeId =
stored.scope.type === "channel" ? context?.channelId : context?.threadId;
if (stored.actionId !== context?.actionId || stored.scope.id !== scopeId) {
return null;
}
await stateAdapter.delete(key);
return stored;
} finally {
await stateAdapter.releaseLock(lock);
}
}
export async function postToCallbackUrl(
callbackUrl: string,
payload: Record<string, unknown>
): Promise<{ error?: unknown; status?: number }> {
try {
const response = await fetch(callbackUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
return {
error: new Error(
`Callback URL returned ${response.status}: ${await response.text().catch(() => "")}`
),
status: response.status,
};
}
return { status: response.status };
} catch (error) {
return { error };
}
}