diff --git a/src/components/xt-chat/Bubble/Bubble.vue b/src/components/xt-chat/Bubble/Bubble.vue new file mode 100644 index 0000000..3ff5f5a --- /dev/null +++ b/src/components/xt-chat/Bubble/Bubble.vue @@ -0,0 +1,135 @@ + + + diff --git a/src/components/xt-chat/Bubble/BubbleList.vue b/src/components/xt-chat/Bubble/BubbleList.vue new file mode 100644 index 0000000..51943d4 --- /dev/null +++ b/src/components/xt-chat/Bubble/BubbleList.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/src/components/xt-chat/Bubble/context.ts b/src/components/xt-chat/Bubble/context.ts new file mode 100644 index 0000000..99d1f41 --- /dev/null +++ b/src/components/xt-chat/Bubble/context.ts @@ -0,0 +1,45 @@ +/* + * @Author: RenXiaoDong + * @Date: 2025-08-20 23:17:49 + */ +import { computed, defineComponent, inject, provide, shallowRef, triggerRef, unref, watch } from "vue"; +import type { ComputedRef, InjectionKey } from "vue"; +import { objectType } from "@/utils/type"; +import type { BubbleContextProps } from "./types"; + +const BubbleContextKey: InjectionKey> = + Symbol('BubbleContext'); + +export const globalBubbleContextApi = shallowRef(); + +export const useBubbleContextProvider = (value: ComputedRef) => { + provide(BubbleContextKey, value); + watch( + value, + () => { + globalBubbleContextApi.value = unref(value); + triggerRef(globalBubbleContextApi); + }, + { immediate: true, deep: true }, + ); +}; + +export const useBubbleContextInject = () => { + return inject( + BubbleContextKey, + computed(() => globalBubbleContextApi.value || {}), + ); +}; +export const BubbleContextProvider = defineComponent({ + props: { + value: objectType(), + }, + setup(props, { slots }) { + useBubbleContextProvider(computed(() => props.value)); + return () => { + return slots.default?.(); + }; + }, +}); + +export default BubbleContextProvider; \ No newline at end of file diff --git a/src/components/xt-chat/Bubble/hooks/useDisplayData.ts b/src/components/xt-chat/Bubble/hooks/useDisplayData.ts new file mode 100644 index 0000000..77967f3 --- /dev/null +++ b/src/components/xt-chat/Bubble/hooks/useDisplayData.ts @@ -0,0 +1,58 @@ +/* + * @Author: RenXiaoDong + * @Date: 2025-08-20 23:12:42 + */ +import { computed, unref, watch } from 'vue'; +import type { Ref } from 'vue'; +import { useEventCallback } from '@/hooks/useEventCallback'; +import useState from '@/hooks/useState'; +import type { ListItemType } from './useListData'; + +type UseDisplayDataReturn = [Ref, (value: string | number) => void]; + +export default function useDisplayData(items: Ref): UseDisplayDataReturn { + const [displayCount, setDisplayCount] = useState(items.value.length); + + const displayList = computed(() => items.value.slice(0, unref(displayCount))); + + const displayListLastKey = computed(() => { + const lastItem = unref(displayList)[unref(displayList).length - 1]; + return lastItem ? lastItem.key : null; + }); + + // When `items` changed, we replaced with latest one + watch( + items, + () => { + setDisplayCount(items.value.length); + if ( + unref(displayList).length && + unref(displayList).every((item, index) => item.key === items.value[index]?.key) + ) { + return; + } + + if (unref(displayList).length === 0) { + setDisplayCount(1); + } else { + // Find diff index + for (let i = 0; i < unref(displayList).length; i += 1) { + if (unref(displayList)[i].key !== items.value[i]?.key) { + setDisplayCount(i); + break; + } + } + } + }, + { immediate: true, deep: true }, + ); + + // Continue to show if last one finished typing + const onTypingComplete = useEventCallback((key: string | number) => { + if (key === unref(displayListLastKey)) { + setDisplayCount(unref(displayCount) + 1); + } + }); + + return [displayList, onTypingComplete] as const; +} diff --git a/src/components/xt-chat/Bubble/hooks/useListData.ts b/src/components/xt-chat/Bubble/hooks/useListData.ts new file mode 100644 index 0000000..cd0142d --- /dev/null +++ b/src/components/xt-chat/Bubble/hooks/useListData.ts @@ -0,0 +1,41 @@ +/* + * @Author: RenXiaoDong + * @Date: 2025-08-20 23:14:55 + */ +import { computed, type Ref } from 'vue'; +import type { BubbleDataType, BubbleListProps } from '../types'; +import type { BubbleProps } from '../types'; + +export type UnRef> = T extends Ref ? R : never; + +export type ListItemType = UnRef>[number]; + +export default function useListData( + items: Ref, + roles?: Ref, +) { + const getRoleBubbleProps = (bubble: BubbleDataType, index: number): Partial => { + if (typeof roles.value === 'function') { + return roles.value(bubble, index); + } + + if (roles) { + return roles.value?.[bubble.role!] || {}; + } + + return {}; + } + + const listData = computed(() => + (items.value || []).map((bubbleData, i) => { + const mergedKey = bubbleData.key ?? `preset_${i}`; + + return { + ...getRoleBubbleProps(bubbleData, i), + ...bubbleData, + key: mergedKey, + }; + })); + + return listData as Ref; +} \ No newline at end of file diff --git a/src/components/xt-chat/Bubble/hooks/useTypedEffect.ts b/src/components/xt-chat/Bubble/hooks/useTypedEffect.ts new file mode 100644 index 0000000..1cbe4b9 --- /dev/null +++ b/src/components/xt-chat/Bubble/hooks/useTypedEffect.ts @@ -0,0 +1,75 @@ +/* + * @Author: RenXiaoDong + * @Date: 2025-08-20 23:26:23 + */ +import useState from '@/hooks/useState'; +import { computed, onWatcherCleanup, unref, watch } from 'vue'; +import type { Ref } from 'vue'; +import type { BubbleContentType } from '../types'; + +function isString(str: any): str is string { + return typeof str === 'string'; +} + +/** + * Return typed content and typing status when typing is enabled. + * Or return content directly. + */ +const useTypedEffect = ( + content: Ref, + typingEnabled: Ref, + typingStep: Ref, + typingInterval: Ref, +): [typedContent: Ref, isTyping: Ref] => { + const [prevContent, setPrevContent] = useState(''); + const [typingIndex, setTypingIndex] = useState(1); + + const mergedTypingEnabled = computed(() => typingEnabled.value && isString(content.value)); + + // Reset typing index when content changed + watch( + content, + () => { + const prevContentValue = unref(prevContent); + setPrevContent(content.value); + if (!mergedTypingEnabled.value && isString(content.value)) { + setTypingIndex(content.value.length); + } else if ( + isString(content.value) && + isString(prevContentValue) && + content.value.indexOf(prevContentValue) !== 0 + ) { + setTypingIndex(1); + } + }, + { immediate: true }, + ); + + // Start typing + watch( + [typingIndex, typingEnabled, content], + () => { + if (mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length) { + const id = setTimeout(() => { + setTypingIndex(unref(typingIndex) + typingStep.value); + }, typingInterval.value); + + onWatcherCleanup(() => { + clearTimeout(id); + }); + } + }, + { immediate: true }, + ); + + const mergedTypingContent = computed(() => + mergedTypingEnabled.value && isString(content.value) ? content.value.slice(0, unref(typingIndex)) : content.value, + ); + + return [ + mergedTypingContent, + computed(() => mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length), + ]; +}; + +export default useTypedEffect; diff --git a/src/components/xt-chat/Bubble/hooks/useTypingConfig.ts b/src/components/xt-chat/Bubble/hooks/useTypingConfig.ts new file mode 100644 index 0000000..5ffa015 --- /dev/null +++ b/src/components/xt-chat/Bubble/hooks/useTypingConfig.ts @@ -0,0 +1,37 @@ +/* + * @Author: RenXiaoDong + * @Date: 2025-08-20 23:27:05 + */ +import { computed, toValue, type MaybeRefOrGetter } from 'vue'; +import type { BubbleProps, TypingOption } from '../types'; + +function useTypingConfig(typing: MaybeRefOrGetter) { + const typingEnabled = computed(() => { + if (!toValue(typing)) { + return false; + } + return true; + }); + const baseConfig: Required = { + step: 1, + interval: 50, + // set default suffix is empty + suffix: null, + }; + const config = computed(() => { + const typingRaw = toValue(typing); + return { + ...baseConfig, + ...(typeof typingRaw === 'object' ? typingRaw : {}) + } + }); + + return [ + typingEnabled, + computed(() => config.value.step), + computed(() => config.value.interval), + computed(() => config.value.suffix) + ] as const; +} + +export default useTypingConfig; \ No newline at end of file diff --git a/src/components/xt-chat/Bubble/index.ts b/src/components/xt-chat/Bubble/index.ts new file mode 100644 index 0000000..d98e559 --- /dev/null +++ b/src/components/xt-chat/Bubble/index.ts @@ -0,0 +1,9 @@ +/* + * @Author: RenXiaoDong + * @Date: 2025-08-20 22:16:14 + */ +export { default as Bubble } from './Bubble.vue'; +export { default as BubbleList } from './BubbleList.vue'; +export * from './types'; + + diff --git a/src/components/xt-chat/Bubble/index.vue b/src/components/xt-chat/Bubble/index.vue new file mode 100644 index 0000000..452b383 --- /dev/null +++ b/src/components/xt-chat/Bubble/index.vue @@ -0,0 +1,4 @@ + diff --git a/src/components/xt-chat/Bubble/loading.vue b/src/components/xt-chat/Bubble/loading.vue new file mode 100644 index 0000000..c6e96b8 --- /dev/null +++ b/src/components/xt-chat/Bubble/loading.vue @@ -0,0 +1,29 @@ + + diff --git a/src/components/xt-chat/Bubble/style.scss b/src/components/xt-chat/Bubble/style.scss new file mode 100644 index 0000000..ae35d27 --- /dev/null +++ b/src/components/xt-chat/Bubble/style.scss @@ -0,0 +1,94 @@ +.xt-bubble-list { + gap: 8px; + .xt-bubble { + display: flex; + justify-content: start; + margin: 8px 0; + &.xt-bubble-start { + flex-direction: row; + } + &.xt-bubble-end { + justify-content: end; + flex-direction: row-reverse; + } + .xt-bubble-content { + color: var(--Text-1, #211f24); + font-family: $font-family-regular; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 22px; + .xt-bubble__avatar { + margin: 0 8px; + img { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + } + } + &.xt-bubble-content-round { + display: flex; + justify-content: center; + align-items: center; + border-radius: 50px; + background: var(--BG-200, #f2f3f5); + padding-inline: 0; + } + } + } +} + +.xt-bubble--filled .xt-bubble__content { + background-color: #f5f5f5; +} + +.xt-bubble--outlined .xt-bubble__content { + background-color: #fff; + border: 1px solid #e5e6eb; +} + +.xt-bubble--shadow .xt-bubble__content { + background-color: #fff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.xt-bubble--borderless .xt-bubble__content { + background-color: transparent; +} + +.xt-bubble--shape-default .xt-bubble__content { + border-radius: 10px; +} + +.xt-bubble--shape-round .xt-bubble__content { + border-radius: 18px; +} + +.xt-bubble--shape-corner .xt-bubble__content { + border-radius: 4px; +} + +.xt-bubble__header { + font-size: 12px; + color: #86909c; + margin-bottom: 6px; +} + +.xt-bubble__footer { + font-size: 12px; + color: #86909c; + margin-top: 6px; +} + +.xt-bubble__typing { + display: inline-block; + min-width: 36px; +} + +.xt-bubble-list { + display: flex; + flex-direction: column; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} diff --git a/src/components/xt-chat/Bubble/types.ts b/src/components/xt-chat/Bubble/types.ts new file mode 100644 index 0000000..0bbc1c4 --- /dev/null +++ b/src/components/xt-chat/Bubble/types.ts @@ -0,0 +1,90 @@ +/* + * @Author: RenXiaoDong + * @Date: 2025-08-20 22:04:54 + */ +import type { AvatarProps } from 'ant-design-vue'; +import type { CSSProperties, HTMLAttributes, VNode } from 'vue'; +export type AvoidValidation = T; + +export interface TypingOption { + /** + * @default 1 + */ + step?: number; + /** + * @default 50 + */ + interval?: number; + /** + * @default null + */ + suffix?: VNode | string; +} + +export type SemanticType = 'avatar' | 'content' | 'header' | 'footer'; + +export type BubbleContentType = VNode | string | Record | number; + +export type SlotInfoType = { + key?: string | number; +}; + +export interface _AvatarProps extends AvatarProps { + class: string; + style: CSSProperties; +} + +export interface BubbleProps extends /* @vue-ignore */ Omit { + prefixCls?: string; + rootClassName?: string; + styles?: Partial>; + classNames?: Partial>; + avatar?: Partial<_AvatarProps> | VNode | (() => VNode); + placement?: 'start' | 'end'; + loading?: boolean; + typing?: AvoidValidation; + content?: ContentType; + messageRender?: (content: ContentType) => VNode | string; + loadingRender?: () => VNode; + variant?: 'filled' | 'borderless' | 'outlined' | 'shadow'; + shape?: 'round' | 'corner'; + _key?: number | string; + onTypingComplete?: VoidFunction; + header?: AvoidValidation VNode | string)>; + footer?: AvoidValidation VNode | string)>; +} + +export interface BubbleRef { + nativeElement: HTMLElement; +} + +export interface BubbleContextProps { + onUpdate?: VoidFunction; +} + +export interface BubbleListRef { + nativeElement: HTMLDivElement; + scrollTo: (info: { + offset?: number; + key?: string | number; + behavior?: ScrollBehavior; + block?: ScrollLogicalPosition; + }) => void; +} + +export type BubbleDataType = BubbleProps & { + key?: string | number; + role?: string; +}; + +export type RoleType = Partial, 'content'>>; + +export type RolesType = Record | ((bubbleDataP: BubbleDataType, index: number) => RoleType); + +export interface BubbleListProps extends /* @vue-ignore */ HTMLAttributes { + prefixCls?: string; + rootClassName?: string; + items?: BubbleDataType[]; + autoScroll?: boolean; + roles?: AvoidValidation; +} \ No newline at end of file diff --git a/src/hooks/useEventCallback.ts b/src/hooks/useEventCallback.ts new file mode 100644 index 0000000..1e21f73 --- /dev/null +++ b/src/hooks/useEventCallback.ts @@ -0,0 +1,15 @@ +/* + * @Author: RenXiaoDong + * @Date: 2025-08-20 23:12:14 + */ +import { ref } from 'vue'; + +export function useEventCallback(handler?: (value: T) => void): (value: T) => void { + const callbackRef = ref(handler); + const fn = ref((value: T) => { + callbackRef.value && callbackRef.value(value); + }); + callbackRef.value = handler; + + return fn.value; +} \ No newline at end of file diff --git a/src/hooks/useState.ts b/src/hooks/useState.ts new file mode 100644 index 0000000..7393c43 --- /dev/null +++ b/src/hooks/useState.ts @@ -0,0 +1,21 @@ +/* + * @Author: RenXiaoDong + * @Date: 2025-08-20 23:14:22 + */ +import type { Ref } from 'vue'; +import { ref } from 'vue'; + +export default function useState>( + defaultStateValue?: T | (() => T), +): [R, (val: T) => void] { + const initValue: T = + typeof defaultStateValue === 'function' ? (defaultStateValue as any)() : defaultStateValue; + + const innerValue = ref(initValue) as Ref; + + function triggerChange(newValue: T) { + innerValue.value = newValue; + } + + return [innerValue as unknown as R, triggerChange]; +} \ No newline at end of file diff --git a/src/utils/pick-attrs.ts b/src/utils/pick-attrs.ts new file mode 100644 index 0000000..bf87de6 --- /dev/null +++ b/src/utils/pick-attrs.ts @@ -0,0 +1,80 @@ +/* + * @Author: RenXiaoDong + * @Date: 2025-08-20 23:43:17 + */ +const attributes = `accept acceptcharset accesskey action allowfullscreen allowtransparency +alt async autocomplete autofocus autoplay capture cellpadding cellspacing challenge +charset checked classid class colspan cols content contenteditable contextmenu +controls coords crossorigin data datetime default defer dir disabled download draggable +enctype form formaction formenctype formmethod formnovalidate formtarget frameborder +headers height hidden high href hreflang htmlfor for httpequiv icon id inputmode integrity +is keyparams keytype kind label lang list loop low manifest marginheight marginwidth max maxlength media +mediagroup method min minlength multiple muted name novalidate nonce open +optimum pattern placeholder poster preload radiogroup readonly rel required +reversed role rowspan rows sandbox scope scoped scrolling seamless selected +shape size sizes span spellcheck src srcdoc srclang srcset start step style +summary tabindex target title type usemap value width wmode wrap`; + +const eventsName = `onCopy onCut onPaste onCompositionend onCompositionstart onCompositionupdate onKeydown + onKeypress onKeyup onFocus onBlur onChange onInput onSubmit onClick onContextmenu onDoubleclick onDblclick + onDrag onDragend onDragenter onDragexit onDragleave onDragover onDragstart onDrop onMousedown + onMouseenter onMouseleave onMousemove onMouseout onMouseover onMouseup onSelect onTouchcancel + onTouchend onTouchmove onTouchstart onTouchstartPassive onTouchmovePassive onScroll onWheel onAbort onCanplay onCanplaythrough + onDurationchange onEmptied onEncrypted onEnded onError onLoadeddata onLoadedmetadata + onLoadstart onPause onPlay onPlaying onProgress onRatechange onSeeked onSeeking onStalled onSuspend onTimeupdate onVolumechange onWaiting onLoad onError`; + +const propList = `${attributes} ${eventsName}`.split(/[\s\n]+/); + +/* eslint-enable max-len */ +const ariaPrefix = 'aria-'; +const dataPrefix = 'data-'; + +function match(key: string, prefix: string) { + return key.indexOf(prefix) === 0; +} + +export interface PickConfig { + aria?: boolean; + data?: boolean; + attr?: boolean; +} + +/** + * Picker props from exist props with filter + * @param props Passed props + * @param ariaOnly boolean | { aria?: boolean; data?: boolean; attr?: boolean; } filter config + */ +export default function pickAttrs(props: object, ariaOnly: boolean | PickConfig = false) { + let mergedConfig; + if (ariaOnly === false) { + mergedConfig = { + aria: true, + data: true, + attr: true, + }; + } else if (ariaOnly === true) { + mergedConfig = { + aria: true, + }; + } else { + mergedConfig = { + ...ariaOnly, + }; + } + + const attrs = {}; + Object.keys(props).forEach((key) => { + if ( + // Aria + (mergedConfig.aria && (key === 'role' || match(key, ariaPrefix))) || + // Data + (mergedConfig.data && match(key, dataPrefix)) || + // Attr + (mergedConfig.attr && (propList.includes(key) || propList.includes(key.toLowerCase()))) + ) { + // @ts-ignore + attrs[key] = props[key]; + } + }); + return attrs; +} diff --git a/src/utils/type.ts b/src/utils/type.ts new file mode 100644 index 0000000..3dc49dd --- /dev/null +++ b/src/utils/type.ts @@ -0,0 +1,13 @@ +/* + * @Author: RenXiaoDong + * @Date: 2025-08-20 23:18:36 + */ +import type { PropType, VNode } from 'vue'; + +declare type VNodeChildAtom = VNode | string | number | boolean | null | undefined | void; + +export type VueNode = VNodeChildAtom | VNodeChildAtom[] | VNode; + +export function objectType(defaultVal?: T) { + return { type: Object as PropType, default: defaultVal as T }; +} diff --git a/src/views/home/components/conversation-detail/index.vue b/src/views/home/components/conversation-detail/index.vue index be92259..46c761d 100644 --- a/src/views/home/components/conversation-detail/index.vue +++ b/src/views/home/components/conversation-detail/index.vue @@ -1,6 +1,6 @@