diff --git a/addon/components/utils/smart-conversation/message.hbs b/addon/components/utils/smart-conversation/message.hbs new file mode 100644 index 00000000..976fc29b --- /dev/null +++ b/addon/components/utils/smart-conversation/message.hbs @@ -0,0 +1,22 @@ +
+
+ {{#if (eq @type "smart_reply")}} + + {{/if}} + +
+ {{@value}} + + {{#if (has-block "extra-content")}} + {{yield to="extra-content"}} + {{/if}} +
+
+ + {{this.formattedTimestamp}} +
diff --git a/addon/components/utils/smart-conversation/message.ts b/addon/components/utils/smart-conversation/message.ts new file mode 100644 index 00000000..74943e5d --- /dev/null +++ b/addon/components/utils/smart-conversation/message.ts @@ -0,0 +1,43 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import moment from 'moment'; + +interface UtilsSmartConversationMessageComponentSignature { + type: 'smart_reply' | 'user_prompt'; + collapsible?: boolean; + value: string; + timestamp: number; +} + +export default class UtilsSmartConversationMessageComponent extends Component { + @tracked collapsed: boolean = true; + + get computedClasses(): string { + const classes = ['smart-conversation-message', `smart-conversation-message--${this.args.type}`]; + + if (this.collapsible && this.collapsed) { + classes.push('smart-conversation-message--collapsed'); + } + + return classes.join(' '); + } + + get formattedTimestamp(): string { + return moment(this.args.timestamp).format('DD/MM/YYYY, HH:mm'); + } + + @action + toggleCollapsed(event: MouseEvent): void { + event.stopPropagation(); + + if (!this.collapsible) return; + + this.collapsed = !this.collapsed; + } + + private get collapsible(): boolean { + return this.args.collapsible ?? true; + } +} diff --git a/app/components/utils/smart-conversation/message.js b/app/components/utils/smart-conversation/message.js new file mode 100644 index 00000000..600c1baf --- /dev/null +++ b/app/components/utils/smart-conversation/message.js @@ -0,0 +1 @@ +export { default } from '@upfluence/ember-upf-utils/components/utils/smart-conversation/message'; diff --git a/app/styles/components/smart-conversation.less b/app/styles/components/smart-conversation.less new file mode 100644 index 00000000..be4f75e9 --- /dev/null +++ b/app/styles/components/smart-conversation.less @@ -0,0 +1,48 @@ +@import url('https://fonts.googleapis.com/css?family=Reddit+Sans:wght@400,600&family=Open+Sans:400,400i,600,600i,700,700i&display=swap&subset=cyrillic,cyrillic-ext,greek,greek-ext,latin-ext,vietnamese'); + +.smart-conversation-message { + display: flex; + flex-direction: column; + gap: var(--spacing-px-3); + align-items: flex-end; + max-width: 70%; + + .content { + border-radius: var(--border-radius-lg); + display: flex; + gap: var(--spacing-px-18); + padding: var(--spacing-px-12) var(--spacing-px-18); + font-size: var(--font-size-md); + font-family: 'Reddit Sans', sans-serif; + position: relative; + } + + &--smart_reply .content { + border: 1px solid transparent; + background: linear-gradient(45deg, var(--color-white) 0%, var(--color-primary-50) 100%) padding-box, + linear-gradient(90deg, var(--color-primary-100) 0%, var(--color-white) 72.5%) border-box; + + color: var(--color-primary-400); + } + + &--user_prompt .content { + background-color: var(--color-gray-100); + color: var(--color-gray-900); + padding: var(--spacing-px-12); + } + + &--collapsed .content { + max-height: 130px; + overflow-y: hidden; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 40px; + background: linear-gradient(to top, var(--color-gray-50), transparent 40px); + } + } +} diff --git a/app/styles/upf-utils.less b/app/styles/upf-utils.less index 298ee52b..58e09df7 100644 --- a/app/styles/upf-utils.less +++ b/app/styles/upf-utils.less @@ -8,6 +8,7 @@ @import 'components/http-errors-code'; @import 'components/logo-maker'; @import 'components/smart-blob'; +@import 'components/smart-conversation'; @import 'components/uedit-file-uploader'; @import 'components/utm-link-builder'; @import 'components/utils/social-media-handler'; diff --git a/tests/integration/components/utils/smart-conversation/message-test.ts b/tests/integration/components/utils/smart-conversation/message-test.ts new file mode 100644 index 00000000..17df3be2 --- /dev/null +++ b/tests/integration/components/utils/smart-conversation/message-test.ts @@ -0,0 +1,147 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { click, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import moment from 'moment'; + +module('Integration | Component | utils/smart-conversation/message', function (hooks) { + setupRenderingTest(hooks); + + module('User prompt', function (hooks) { + hooks.beforeEach(function () { + this.type = 'user_prompt'; + this.value = 'Gimme, gimme, gimme a creator after midnight'; + this.timestamp = moment('2026-05-05').valueOf(); + }); + + test('it renders properly', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.smart-conversation-message').exists(); + assert.dom('.smart-conversation-message').hasClass('smart-conversation-message--user_prompt'); + assert.dom('.smart-conversation-message--collapsed').exists(); + assert.dom('.smart-conversation-message .content').hasText('Gimme, gimme, gimme a creator after midnight'); + assert.dom('.smart-conversation-message span.font-color-gray-400').hasText('05/05/2026, 00:00'); + }); + + test('the extra-content named block is rendered when provided', async function (assert) { + await render( + hbs` + + <:extra-content> +
Extra content
+ +
+ ` + ); + + assert.dom('.extra-content').exists(); + assert.dom('.extra-content').hasText('Extra content'); + }); + }); + + module('Smart reply', function (hooks) { + hooks.beforeEach(function () { + this.type = 'smart_reply'; + this.value = 'This is a smart reply'; + this.timestamp = moment('2026-04-22').valueOf(); + }); + + test('it renders properly', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.smart-conversation-message').exists(); + assert.dom('.smart-conversation-message').hasClass('smart-conversation-message--smart_reply'); + assert.dom('.smart-conversation-message').hasClass('smart-conversation-message--collapsed'); + assert.dom('.smart-conversation-message .content').hasText(this.value); + assert.dom('.smart-conversation-message span.font-color-gray-400').hasText('22/04/2026, 00:00'); + }); + + test('the extra-content named block is rendered when provided', async function (assert) { + await render( + hbs` + + <:extra-content> +
Extra content
+ +
+ ` + ); + + assert.dom('.extra-content').exists(); + assert.dom('.extra-content').hasText('Extra content'); + }); + }); + + module('Collapsible message', function () { + ['user_prompt', 'smart_reply'].forEach((type) => { + hooks.beforeEach(function () { + this.type = type; + }); + + test(`By default, message is collapsible for ${type}`, async function (assert) { + await render( + hbs`` + ); + + assert.dom('.smart-conversation-message--collapsed').exists(); + + await click('.smart-conversation-message'); + assert.dom('.smart-conversation-message--collapsed').doesNotExist(); + }); + + test('when no @collapsible argument is provided, it defaults to true', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.smart-conversation-message--collapsed').exists(); + }); + + test('when a falsy @collapsible argument is provided, message is not collapsed', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.smart-conversation-message--collapsed').doesNotExist(); + }); + + test('when a falsy @collapsible argument is provided, message is not collapsible', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.smart-conversation-message--collapsed').doesNotExist(); + + await click('.smart-conversation-message'); + assert.dom('.smart-conversation-message--collapsed').doesNotExist(); + }); + + test('when a truthy @collapsible argument is provided, message is collapsed', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.smart-conversation-message--collapsed').exists(); + }); + + test('it toggles collapsed state on click', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.smart-conversation-message--collapsed').exists(); + + await click('.smart-conversation-message'); + assert.dom('.smart-conversation-message--collapsed').doesNotExist(); + + await click('.smart-conversation-message'); + assert.dom('.smart-conversation-message--collapsed').exists(); + }); + }); + }); +});