From 015eb84a92e5fde81d7bcd96ba73b001dac00ac3 Mon Sep 17 00:00:00 2001 From: Leonardo Vieira Date: Fri, 19 Jun 2026 08:15:23 -0300 Subject: [PATCH] fix(consumers): replace Slack checkboxes with interactive toggle lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack rejects a `checkboxes` element with more than 10 options as `invalid_blocks` (HTTP 404 on the response_url POST). The DAO list (11) and notification Settings (12 types) had both grown past that cap, so clicking "Manage DAOs" or "Settings" failed with "Sorry, there was an error loading...". Telegram was unaffected (no such limit). Replace both with per-item toggle buttons that save on click and re-render the message in place: - DAOs: a grid of toggle buttons (daoToggleList) — green + checkmark when tracked - Settings: a list of rows, each notification type with an On/Off button accessory A "Done" button collapses the list into a summary; for the DAO onboarding flow it still advances to the wallet step. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/services/bot/slack-bot.service.ts | 22 ++- .../src/services/dao/slack-dao.service.ts | 104 ++++++----- .../settings/slack-settings.service.ts | 168 ++++++++++-------- .../src/utils/slack-blocks-templates.ts | 54 ++++-- 4 files changed, 211 insertions(+), 137 deletions(-) diff --git a/apps/consumers/src/services/bot/slack-bot.service.ts b/apps/consumers/src/services/bot/slack-bot.service.ts index 60342e9b..18cac018 100644 --- a/apps/consumers/src/services/bot/slack-bot.service.ts +++ b/apps/consumers/src/services/bot/slack-bot.service.ts @@ -96,8 +96,15 @@ export class SlackBotService implements BotServiceInterface { } }); - handlers.action('dao_checkboxes', async (ctx) => { - await ctx.ack(); + handlers.action(/^dao_toggle_/, async (ctx) => { + if (this.daoService) { + const daoId = ctx.body.actions?.[0]?.value; + if (daoId) { + await this.daoService.toggle(ctx, daoId); + } else { + await ctx.ack(); + } + } }); @@ -140,8 +147,15 @@ export class SlackBotService implements BotServiceInterface { } }); - handlers.action('settings_checkboxes', async (ctx) => { - await ctx.ack(); + handlers.action(/^settings_toggle_/, async (ctx) => { + if (this.settingsService) { + const typeId = ctx.body.actions?.[0]?.value; + if (typeId) { + await this.settingsService.toggle(ctx, typeId); + } else { + await ctx.ack(); + } + } }); handlers.action('settings_confirm', async (ctx) => { diff --git a/apps/consumers/src/services/dao/slack-dao.service.ts b/apps/consumers/src/services/dao/slack-dao.service.ts index 2fd652fb..e78f791e 100644 --- a/apps/consumers/src/services/dao/slack-dao.service.ts +++ b/apps/consumers/src/services/dao/slack-dao.service.ts @@ -9,9 +9,8 @@ import { SlackCommandContext, SlackActionContext } from '../../interfaces/slack- export type SlackDAORequest = Pick; import { slackMessages, replacePlaceholders } from '@notification-system/messages'; -import type { ViewStateSelectedOption } from '@slack/bolt'; import { - daoSelectionList, + daoToggleList, errorMessage, daoEmptyState, daoListWithEdit @@ -50,10 +49,10 @@ export class SlackDAOService extends BaseDAOService { const userPreferences = await this.getUserSubscriptions(fullUserId); const currentSelections = new Set(userPreferences); - const blocks = daoSelectionList( + const blocks = daoToggleList( daos, currentSelections, - 'dao_checkboxes', + 'dao_toggle', 'dao_confirm_subscribe', slackMessages.dao.subscribeInstructions ); @@ -121,64 +120,83 @@ export class SlackDAOService extends BaseDAOService { } /** - * Confirm DAO selection changes from checkboxes + * Toggle a single DAO subscription on/off, then re-render the list in place. + * Option 2 UI: each DAO is a button that saves immediately on click — there is + * no batch "confirm" step for the selection itself. */ - async confirm(context: SlackActionContext): Promise { - const channelId = context.body.channel?.id; - const workspaceId = context.body.team?.id || context.body.user?.team_id; + async toggle(context: SlackActionContext, daoId: string): Promise { + const channelId = context.body.channel?.id || context.body.channel_id; + const workspaceId = context.body.team?.id || context.body.team_id || context.body.user?.team_id; const fullUserId = `${workspaceId}:${channelId}`; try { await context.ack(); - // Extract selected DAOs from checkbox state - const state = context.body.state; - if (typeof state === 'string') { - throw new Error('Unexpected DialogAction state format'); + const normalized = daoId.toUpperCase(); + const daos = await this.fetchAvailableDAOs(); + const current = new Set( + await this.subscriptionApi.getUserPreferences(fullUserId, this.getPlatformId(), daos.map(dao => dao.id)) + ); + + // Flip just the clicked DAO and persist that single change + const willSubscribe = !current.has(normalized); + await this.subscriptionApi.saveUserPreference(normalized, fullUserId, this.getPlatformId(), willSubscribe); + if (willSubscribe) { + current.add(normalized); + } else { + current.delete(normalized); } - const selectedOptions: ViewStateSelectedOption[] = - state?.values?.dao_checkboxes_block?.dao_checkboxes?.selected_options || []; - const selectedDAOs = new Set(selectedOptions.map(opt => opt.value)); + if (context.respond) { + await context.respond({ + blocks: daoToggleList(daos, current, 'dao_toggle', 'dao_confirm_subscribe', slackMessages.dao.subscribeInstructions), + response_type: 'in_channel', + replace_original: true + }); + } + } catch (error) { + this.logger.error({ err: error, event: 'dao.toggle_failed' }, 'error toggling DAO subscription'); + if (context.respond) { + await context.respond({ + text: slackMessages.dao.updateError, + response_type: 'in_channel' + }); + } + } + } - // Sync to the complete desired state (handles both adds and removes) - await this.syncSubscriptionsToState(fullUserId, selectedDAOs); + /** + * "Done" button. Subscriptions were already saved per-click via toggle(), so + * this just collapses the button list into a final summary. Onboarding advance + * (the wallet step) is triggered by the action handler in slack-bot.service. + */ + async confirm(context: SlackActionContext): Promise { + const channelId = context.body.channel?.id || context.body.channel_id; + const workspaceId = context.body.team?.id || context.body.team_id || context.body.user?.team_id; + const fullUserId = `${workspaceId}:${channelId}`; - // Show confirmation message - let successMessage: string; - if (selectedDAOs.size === 0) { - successMessage = slackMessages.dao.unsubscribeAllSuccess; - } else { - const daoList = this.formatDAOList(selectedDAOs); - successMessage = replacePlaceholders(slackMessages.dao.subscribeSuccess, { daoList }); - } + try { + await context.ack(); + + const daos = await this.fetchAvailableDAOs(); + const current = await this.subscriptionApi.getUserPreferences(fullUserId, this.getPlatformId(), daos.map(dao => dao.id)); + + const summaryMessage = current.length === 0 + ? slackMessages.dao.unsubscribeAllSuccess + : replacePlaceholders(slackMessages.dao.subscribeSuccess, { daoList: this.formatDAOList(current) }); if (context.respond) { await context.respond({ - replace_original: false, + replace_original: true, blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: successMessage - } - }, - { - type: 'context', - elements: [ - { - type: 'mrkdwn', - text: slackMessages.dao.updateInstructions - } - ] - } + { type: 'section', text: { type: 'mrkdwn', text: summaryMessage } }, + { type: 'context', elements: [{ type: 'mrkdwn', text: slackMessages.dao.updateInstructions }] } ], response_type: 'in_channel' }); } } catch (error) { - this.logger.error({ err: error, event: 'dao.update_failed' }, 'error updating subscriptions'); + this.logger.error({ err: error, event: 'dao.confirm_failed' }, 'error finalizing DAO selection'); if (context.respond) { await context.respond({ replace_original: false, diff --git a/apps/consumers/src/services/settings/slack-settings.service.ts b/apps/consumers/src/services/settings/slack-settings.service.ts index 56ec4b48..77912e95 100644 --- a/apps/consumers/src/services/settings/slack-settings.service.ts +++ b/apps/consumers/src/services/settings/slack-settings.service.ts @@ -1,4 +1,5 @@ import { NOTIFICATION_TYPES, NotificationTypeId } from '@notification-system/messages'; +import type { KnownBlock } from '@slack/web-api'; import { BaseSettingsService } from './base-settings.service'; import { SubscriptionAPIService } from '../subscription-api.service'; import { SlackActionContext } from '../../interfaces/slack-context.interface'; @@ -9,6 +10,45 @@ export class SlackSettingsService extends BaseSettingsService { super(subscriptionApi, 'slack', logger); } + /** + * Settings UI as a list of rows — one notification type per row with a toggle + * button on the right (✅ On / Off). Each button is a per-row toggle (action_id + * `settings_toggle_`, id in `value`) that saves immediately on click. Rows + * are `section` blocks with a `button` accessory; 12 types → ~17 blocks, well + * under the 50-block message limit. (Checkboxes cap at 10 and a multi-select is + * a dropdown, so neither gives a friendly, fully-visible list — buttons do.) + */ + private buildSettingsBlocks(preferences: Record): KnownBlock[] { + const rows = Object.values(NotificationTypeId).map((id) => ({ + type: 'section' as const, + text: { type: 'mrkdwn' as const, text: NOTIFICATION_TYPES[id] }, + accessory: { + type: 'button' as const, + text: { type: 'plain_text' as const, text: preferences[id] ? '✅ On' : 'Off', emoji: true }, + action_id: `settings_toggle_${id}`, + value: id, + ...(preferences[id] ? { style: 'primary' as const } : {}), + }, + })); + + return [ + { type: 'header' as const, text: { type: 'plain_text' as const, text: '⚙️ Notification Settings' } }, + { type: 'section' as const, text: { type: 'mrkdwn' as const, text: 'Toggle the notifications you want to receive — changes save instantly.' } }, + { type: 'divider' as const }, + ...rows, + { type: 'divider' as const }, + { + type: 'actions' as const, + elements: [{ + type: 'button' as const, + text: { type: 'plain_text' as const, text: '✅ Done' }, + action_id: 'settings_confirm', + style: 'primary' as const, + }], + }, + ]; + } + async initialize(ctx: SlackActionContext): Promise { const channelId = ctx.body.channel?.id || ctx.body.channel_id; const workspaceId = ctx.body.team?.id || ctx.body.team_id || ctx.body.user?.team_id; @@ -20,55 +60,12 @@ export class SlackSettingsService extends BaseSettingsService { const preferences = await this.loadPreferences(fullUserId); ctx.session.notificationSelections = preferences; - const notificationTypeIds = Object.values(NotificationTypeId); - const options = notificationTypeIds.map(id => ({ - text: { type: 'plain_text' as const, text: NOTIFICATION_TYPES[id] }, - value: id, - })); - - const initialOptions = notificationTypeIds - .filter(id => preferences[id]) - .map(id => ({ - text: { type: 'plain_text' as const, text: NOTIFICATION_TYPES[id] }, - value: id, - })); - - const blocks = [ - { - type: 'header' as const, - text: { type: 'plain_text' as const, text: '⚙️ Notification Settings' } - }, - { - type: 'section' as const, - text: { type: 'mrkdwn' as const, text: 'Choose which notifications you want to receive:' } - }, - { - type: 'actions' as const, - block_id: 'settings_checkboxes_block', - elements: [{ - type: 'checkboxes' as const, - action_id: 'settings_checkboxes', - options, - ...(initialOptions.length > 0 ? { initial_options: initialOptions } : {}) - }] - }, - { - type: 'actions' as const, - elements: [{ - type: 'button' as const, - text: { type: 'plain_text' as const, text: '✅ Save Settings' }, - action_id: 'settings_confirm', - style: 'primary' as const - }] - } - ]; - if (ctx.respond) { await ctx.respond({ - blocks, + blocks: this.buildSettingsBlocks(preferences), text: 'Notification Settings', response_type: 'in_channel', - replace_original: false + replace_original: false, }); } } catch (error) { @@ -76,13 +73,17 @@ export class SlackSettingsService extends BaseSettingsService { if (ctx.respond) { await ctx.respond({ text: 'Sorry, there was an error loading your settings. Please try again later.', - response_type: 'ephemeral' + response_type: 'ephemeral', }); } } } - async confirm(ctx: SlackActionContext): Promise { + /** + * Toggle a single notification type on/off, persist it, and re-render in place. + * Option 3 UI: each type is a row whose button saves immediately on click. + */ + async toggle(ctx: SlackActionContext, typeId: string): Promise { const channelId = ctx.body.channel?.id || ctx.body.channel_id; const workspaceId = ctx.body.team?.id || ctx.body.team_id || ctx.body.user?.team_id; const fullUserId = `${workspaceId}:${channelId}`; @@ -90,43 +91,66 @@ export class SlackSettingsService extends BaseSettingsService { try { await ctx.ack(); - // Extract selected checkbox values from state - const stateValues = typeof ctx.body.state === 'object' ? ctx.body.state?.values : undefined; - const selectedValues = new Set(); - - if (stateValues) { - const checkboxBlock = stateValues['settings_checkboxes_block']; - if (checkboxBlock?.['settings_checkboxes']) { - const selectedOptions = checkboxBlock['settings_checkboxes'].selected_options || []; - for (const option of selectedOptions) { - if (option.value) { - selectedValues.add(option.value); - } - } - } - } + const id = typeId as NotificationTypeId; + const preferences = await this.loadPreferences(fullUserId); + preferences[id] = !preferences[id]; + await this.savePreferences(fullUserId, preferences); + ctx.session.notificationSelections = preferences; - // Build selections record: selected = true, unselected = false - const selections = {} as Record; - for (const id of Object.values(NotificationTypeId)) { - selections[id] = selectedValues.has(id); + if (ctx.respond) { + await ctx.respond({ + blocks: this.buildSettingsBlocks(preferences), + text: 'Notification Settings', + response_type: 'in_channel', + replace_original: true, + }); } + } catch (error) { + this.logger.error({ err: error, event: 'settings.toggle_failed' }, 'error toggling notification setting'); + if (ctx.respond) { + await ctx.respond({ + text: '❌ Failed to update your settings. Please try again.', + response_type: 'ephemeral', + }); + } + } + } + + /** + * "Done" button. Preferences were already saved per-click via toggle(), so this + * only collapses the list into a final summary. + */ + async confirm(ctx: SlackActionContext): Promise { + const channelId = ctx.body.channel?.id || ctx.body.channel_id; + const workspaceId = ctx.body.team?.id || ctx.body.team_id || ctx.body.user?.team_id; + const fullUserId = `${workspaceId}:${channelId}`; + + try { + await ctx.ack(); + + const preferences = await this.loadPreferences(fullUserId); + const enabled = Object.values(NotificationTypeId) + .filter(id => preferences[id]) + .map(id => NOTIFICATION_TYPES[id]); - await this.savePreferences(fullUserId, selections); + const summary = enabled.length === 0 + ? '🔕 All notifications are turned off.' + : `✅ You'll receive: ${enabled.join(', ')}.`; if (ctx.respond) { await ctx.respond({ - text: '✅ Your notification preferences have been saved!', + blocks: [{ type: 'section', text: { type: 'mrkdwn', text: summary } }], + text: 'Notification settings saved', response_type: 'in_channel', - replace_original: false + replace_original: true, }); } } catch (error) { - this.logger.error({ err: error, event: 'settings.save_failed' }, 'error saving notification settings'); + this.logger.error({ err: error, event: 'settings.save_failed' }, 'error finalizing notification settings'); if (ctx.respond) { await ctx.respond({ - text: '❌ Failed to save your preferences. Please try again.', - response_type: 'ephemeral' + text: '❌ Something went wrong. Please try again.', + response_type: 'ephemeral', }); } } diff --git a/apps/consumers/src/utils/slack-blocks-templates.ts b/apps/consumers/src/utils/slack-blocks-templates.ts index 1b95c9c1..781aa2c3 100644 --- a/apps/consumers/src/utils/slack-blocks-templates.ts +++ b/apps/consumers/src/utils/slack-blocks-templates.ts @@ -109,32 +109,50 @@ export const checkboxSelectionList = ( * DAO selection list with checkboxes * Wrapper around checkboxSelectionList with DAO-specific formatting */ -export const daoSelectionList = ( +export const daoToggleList = ( daos: Array<{ id: string; name?: string }>, selectedIds: Set, - actionPrefix: string, - confirmActionId: string, + toggleActionPrefix: string, + doneActionId: string, headerText: string ): KnownBlock[] => { - // Format DAOs with emojis - const items = daos.map(dao => { + // Option 2 UI: one button per DAO so the whole list stays visible. Neither a + // `checkboxes` element (caps at 10 options) nor a `multi_static_select` (a + // dropdown) fits a friendly >10 list — buttons do. Selected DAOs get a ✅ and + // primary (green) style. Each button's action_id must be unique within a block + // (`dao_toggle_`); the DAO id also rides in `value` for the handler to read. + // Clicking a button saves immediately (see SlackDAOService.toggle). + const toggleButtons = daos.map(dao => { const daoId = dao.id.toUpperCase(); const emoji = daoEmojis.get(daoId) || defaultDaoEmoji; - return { - value: daoId, - displayText: `${emoji} *${daoId}*` - }; + const isOn = selectedIds.has(daoId); + return button( + `${isOn ? '✅ ' : ''}${emoji} ${daoId}`, + `${toggleActionPrefix}_${daoId}`, + isOn ? { style: 'primary', value: daoId } : { value: daoId }, + ); }); - return checkboxSelectionList( - items, - selectedIds, - actionPrefix, - 'dao_checkboxes_block', - confirmActionId, - headerText, - 'primary' - ); + // An actions block holds at most 25 elements, so chunk the buttons across blocks. + const buttonBlocks: ActionsBlock[] = []; + for (let i = 0; i < toggleButtons.length; i += 25) { + buttonBlocks.push(actions(...toggleButtons.slice(i, i + 25))); + } + + return [ + section(headerText), + ...buttonBlocks, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Tracking *${selectedIds.size}* of *${daos.length}* — changes save as you click.`, + }, + ], + }, + actions(button('✅ Done', doneActionId, { style: 'primary' })), + ]; }; /**