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' })), + ]; }; /**