Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f43c6ad
feat: add required changes
pipech Apr 6, 2026
bca15c6
feat: add channel name fix
pipech Apr 9, 2026
185c7cf
feat: add omni channel chat
pipech Apr 19, 2026
3a8f6ec
refactor: remove async io
pipech Apr 19, 2026
3f433af
refactor: convert webhook handler into class
pipech Apr 19, 2026
c8305ba
fix: rename webhook handler
pipech Apr 19, 2026
d5c26d4
refactor: improve gate
pipech Apr 19, 2026
f7251d4
refactor: update connector
pipech Apr 19, 2026
f104e49
refactor: rename class name
pipech Apr 19, 2026
4b79c82
refactor: move webhook handler into provider
pipech Apr 20, 2026
2cd8342
feat: add file and image inbound support
pipech Apr 20, 2026
2deefb5
feat: add file and image handler
pipech Apr 20, 2026
bf1fc1c
feat: add field to workspace
pipech Apr 20, 2026
eed04ff
feat: update omni channel ui
pipech Apr 20, 2026
5d62552
feat: add instagram integration
pipech Apr 20, 2026
733a669
refactor: update webhook interface
pipech Apr 20, 2026
9f0dd16
refactor: add standardize data class
pipech Apr 20, 2026
179b6f5
refactor: update msg object
pipech Apr 20, 2026
691d743
refactor: msg object
pipech Apr 21, 2026
5273fb4
fix: remove blank provider
pipech Apr 21, 2026
e40a6ac
refactor: remove instagram, it hasn't been test yet
pipech Apr 21, 2026
b1d733d
fix: json error on omni channel chat provider
pipech Apr 21, 2026
2dd7def
refactor: change to generic type
pipech Apr 21, 2026
6089fb2
fix: update msg interface
pipech Apr 21, 2026
a4d1445
refactor: message
pipech Apr 21, 2026
b7ef547
refactor: webhook handler
pipech Apr 21, 2026
f72fd08
refactor: minor cleanup
pipech Apr 21, 2026
e4a90c6
docs: add readme
pipech Apr 21, 2026
0fdd782
refactor: container
pipech Apr 21, 2026
ff910b3
refactor: connector func name
pipech Apr 22, 2026
80889c6
feat: support group chat
pipech Apr 22, 2026
b74728b
feat: support line group
pipech Apr 22, 2026
5d58560
fix: ui logic
pipech Apr 22, 2026
aa41c88
fix: channel creation method
pipech Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 53 additions & 12 deletions frontend/src/components/feature/chat-header/ChannelHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,42 @@ import { PageHeader } from "@/components/layout/Heading/PageHeader"
import { ChannelIcon } from "@/utils/layout/channelIcon"
import { ChannelListItem } from "@/utils/channel/ChannelListProvider"
import { EditChannelNameButton } from "../channel-details/rename-channel/EditChannelNameButton"
import { Flex, Heading } from "@radix-ui/themes"
import { Flex, Heading, Text } from "@radix-ui/themes"
import ChannelHeaderMenu from "./ChannelHeaderMenu"
import { ViewChannelMemberAvatars } from "./ViewChannelMemberAvatars"
import { BiChevronLeft } from "react-icons/bi"
import { Link } from "react-router-dom"
import { ViewPinnedMessagesButton } from "../pinned-messages/ViewPinnedMessagesButton"
import { useAtomValue } from "jotai"
import { lastWorkspaceAtom } from "@/utils/lastVisitedAtoms"
import { useContext, useMemo } from "react"
import { UserListContext } from "@/utils/users/UserListProvider"
import { UserAvatar } from "@/components/common/UserAvatar"
import { FaFacebook, FaLine } from "react-icons/fa"

const PROVIDER_ICONS: Record<string, React.ReactNode> = {
line: <FaLine className='text-[#06C755]' />,
facebook: <FaFacebook className='text-[#1877F2]' />,
}

interface ChannelHeaderProps {
channelData: ChannelListItem
}

export const ChannelHeader = ({ channelData }: ChannelHeaderProps) => {

// The channel header has the channel name, the channel type icon, edit channel name button, and the view or add members button

const lastWorkspace = useAtomValue(lastWorkspaceAtom)
const { users } = useContext(UserListContext)

const customerUser = useMemo(
() => users.find(u => u.name === channelData.omni_channel_raven_user),
[users, channelData.omni_channel_raven_user]
)

const isOmniChannel = !!channelData.omni_channel_raven_user
const displayName = customerUser?.full_name ?? channelData.channel_name
const providerIcon = channelData.omni_channel_provider ? PROVIDER_ICONS[channelData.omni_channel_provider] : null
const providerDisplayName = channelData.omni_channel_display_name ?? channelData.omni_channel_chat_provider

return (
<PageHeader>
Expand All @@ -28,15 +46,38 @@ export const ChannelHeader = ({ channelData }: ChannelHeaderProps) => {
<BiChevronLeft size='24' className="block text-gray-12" />
</Link>
<Flex gap='4' align={'center'} className="group animate-fadein pr-4">
<Flex gap='1' align={'center'}>
<ChannelIcon type={channelData.type} size='18' />
<Heading
size={{
initial: '4',
sm: '5'
}}
className="mb-0.5 text-ellipsis line-clamp-1">{channelData.channel_name}</Heading>
</Flex>
{isOmniChannel ? (
<Flex gap='2' align='center'>
<UserAvatar
src={customerUser?.user_image}
alt={displayName}
size='2'
/>
<Heading
size={{ initial: '4', sm: '5' }}
className="mb-0.5 text-ellipsis line-clamp-1"
>
{displayName}
</Heading>
{providerDisplayName && (
<>
<Text color='gray' size='4'>|</Text>
{providerIcon && <span className='flex items-center text-base'>{providerIcon}</span>}
<Text size='3' color='gray' className='text-ellipsis line-clamp-1'>{providerDisplayName}</Text>
</>
)}
</Flex>
) : (
<Flex gap='1' align={'center'}>
<ChannelIcon type={channelData.type} size='18' />
<Heading
size={{
initial: '4',
sm: '5'
}}
className="mb-0.5 text-ellipsis line-clamp-1">{channelData.channel_name}</Heading>
</Flex>
)}
<EditChannelNameButton channelID={channelData.name} channel_name={channelData.channel_name} channelType={channelData.type} disabled={channelData.is_archived == 1} buttonVisible={!!channelData.pinned_messages_string} />
<ViewPinnedMessagesButton pinnedMessagesString={channelData.pinned_messages_string ?? ''} />
</Flex>
Expand Down
170 changes: 170 additions & 0 deletions frontend/src/components/layout/Sidebar/OmniChannelSidebarBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { useContext, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { Flex, ScrollArea, Text } from '@radix-ui/themes'
import { ChannelListContext, ChannelListContextType, ChannelListItem } from '@/utils/channel/ChannelListProvider'
import {
SidebarBadge,
SidebarGroup,
SidebarGroupItem,
SidebarGroupLabel,
SidebarGroupList,
SidebarIcon,
SidebarItem,
SidebarViewMoreButton,
} from './SidebarComp'
import { UserListContext } from '@/utils/users/UserListProvider'
import { UserAvatar } from '@/components/common/UserAvatar'
import { useFetchUnreadMessageCount } from '@/hooks/useUnreadMessageCount'
import { useStickyState } from '@/hooks/useStickyState'
import { __ } from '@/utils/translations'
import { FaFacebook, FaLine } from 'react-icons/fa'

type OmniChannelItem = ChannelListItem & { unread_count: number }

interface ProviderGroup {
provider?: string
channels: OmniChannelItem[]
}

const PROVIDER_ICONS: Record<string, React.ReactNode> = {
line: <FaLine className='text-[#06C755]' />,
facebook: <FaFacebook className='text-[#1877F2]' />,
}

export const OmniChannelSidebarBody = () => {
const { channels } = useContext(ChannelListContext) as ChannelListContextType
const { workspaceID } = useParams()
const unread_count = useFetchUnreadMessageCount()

const channelsWithUnread = useMemo(() => {
const workspaceChannels = channels.filter(c => c.workspace === workspaceID && !c.is_archived)
return workspaceChannels.map(c => ({
...c,
unread_count: unread_count?.message?.find(u => u.name === c.name)?.unread_count ?? 0,
})) as OmniChannelItem[]
}, [channels, workspaceID, unread_count])

const unreadChannels = useMemo(() => channelsWithUnread.filter(c => c.unread_count > 0), [channelsWithUnread])

const groupedChannels = useMemo(() => {
const groups: Record<string, ProviderGroup> = {}
channelsWithUnread.forEach(c => {
const key = c.omni_channel_display_name ?? c.omni_channel_chat_provider ?? 'Other'
if (!groups[key]) groups[key] = { provider: c.omni_channel_provider, channels: [] }
groups[key].channels.push(c)
})
return groups
}, [channelsWithUnread])

return (
<ScrollArea type="hover" scrollbars="vertical" className='h-[calc(100vh-4rem)]'>
<Flex direction='column' gap='2' className='overflow-x-hidden pb-12 sm:pb-0' px='2'>
{unreadChannels.length > 0 && (
<OmniProviderGroup
label={__("Unread")}
channels={unreadChannels}
storageKey="omni_unread"
showProvider
/>
)}
{Object.entries(groupedChannels).map(([label, group]) => (
<OmniProviderGroup
key={label}
label={label}
channels={group.channels}
provider={group.provider}
storageKey={`omni_provider_${label}`}
/>
))}
</Flex>
</ScrollArea>
)
}

interface OmniProviderGroupProps {
label: string
channels: OmniChannelItem[]
storageKey: string
provider?: string
showProvider?: boolean
}

const OmniProviderGroup = ({ label, channels, storageKey, provider, showProvider }: OmniProviderGroupProps) => {
const [showData, setShowData] = useStickyState(true, storageKey)
const toggle = () => setShowData((d: boolean) => !d)

const ref = useRef<HTMLDivElement>(null)
const [height, setHeight] = useState(ref?.current?.clientHeight ?? (showData ? channels.length * 44 : 0))

useLayoutEffect(() => {
setHeight(ref.current?.clientHeight ?? 0)
}, [channels])

const icon = provider ? PROVIDER_ICONS[provider] : null

return (
<SidebarGroup>
<SidebarGroupItem className='gap-1 pl-1'>
<Flex width='100%' justify='between' align='center' gap='2' pr='2' className='group'>
<Flex align='center' gap='2' width='100%' onClick={toggle} className='cursor-default select-none'>
{icon && <span className='flex items-center text-base'>{icon}</span>}
<SidebarGroupLabel>{label}</SidebarGroupLabel>
</Flex>
<SidebarViewMoreButton onClick={toggle} expanded={showData} />
</Flex>
</SidebarGroupItem>
<SidebarGroup>
<SidebarGroupList style={{ height: showData ? height : 0 }}>
<div ref={ref} className='flex gap-0.5 flex-col'>
{channels.map(channel => (
<OmniChannelItemRow
key={channel.name}
channel={channel}
showProvider={showProvider}
/>
))}
</div>
</SidebarGroupList>
</SidebarGroup>
</SidebarGroup>
)
}

const OmniChannelItemRow = ({ channel, showProvider }: { channel: OmniChannelItem, showProvider?: boolean }) => {
const { users } = useContext(UserListContext)
const { channelID } = useParams()

const customerUser = useMemo(() => users.find(u => u.name === channel.omni_channel_raven_user), [users, channel.omni_channel_raven_user])

const displayName = customerUser?.full_name ?? channel.channel_name
const showUnread = channel.unread_count > 0 && channelID !== channel.name

return (
<SidebarItem to={channel.name} className='py-1 px-2'>
<SidebarIcon>
<UserAvatar
src={customerUser?.user_image}
alt={displayName}
size={{ initial: '2', md: '1' }}
/>
</SidebarIcon>
<Flex direction='column' flexGrow='1' style={{ minWidth: 0 }}>
<Flex justify='between' align='center' width='100%'>
<Text
size={{ initial: '3', md: '2' }}
className='text-ellipsis line-clamp-1'
weight={showUnread ? 'bold' : 'medium'}
>
{displayName}
</Text>
{showUnread && <SidebarBadge>{channel.unread_count}</SidebarBadge>}
</Flex>
{showProvider && (channel.omni_channel_display_name ?? channel.omni_channel_chat_provider) && (
<Text size='1' color='gray' className='text-ellipsis line-clamp-1'>
{channel.omni_channel_display_name ?? channel.omni_channel_chat_provider}
</Text>
)}
</Flex>
</SidebarItem>
)
}
16 changes: 16 additions & 0 deletions frontend/src/components/layout/Sidebar/SidebarBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,27 @@ import { useGetChannelUnreadCounts } from './useGetChannelUnreadCounts'
import { useParams } from 'react-router-dom'
import { atomWithStorage } from 'jotai/utils'
import useUnreadThreadsCount from '@/hooks/useUnreadThreadsCount'
import useFetchWorkspaces from '@/hooks/fetchers/useFetchWorkspaces'
import { OmniChannelSidebarBody } from './OmniChannelSidebarBody'

export const showOnlyMyChannelsAtom = atomWithStorage('showOnlyMyChannels', false)

export const SidebarBody = () => {

const { workspaceID } = useParams()
const { data: workspacesData } = useFetchWorkspaces()

const isOmniChannel = workspacesData?.message.find(w => w.name === workspaceID)?.is_omni_channel_workspace === 1

if (isOmniChannel) {
return <OmniChannelSidebarBody />
}

return <StandardSidebarBody />
}

const StandardSidebarBody = () => {

const unread_count = useFetchUnreadMessageCount()
const { channels, dm_channels } = useContext(ChannelListContext) as ChannelListContextType

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/hooks/fetchers/useFetchWorkspaces.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useFrappeGetCall } from 'frappe-react-sdk'
import { RavenWorkspace } from '@/types/Raven/RavenWorkspace'

export type WorkspaceFields = Pick<RavenWorkspace, 'name' | 'workspace_name' | 'logo' | 'type' | 'can_only_join_via_invite' | 'description'> & {
export type WorkspaceFields = Pick<RavenWorkspace, 'name' | 'workspace_name' | 'logo' | 'type' | 'can_only_join_via_invite' | 'description' | 'is_omni_channel_workspace'> & {
workspace_member_name?: string
is_admin?: 0 | 1
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types/Raven/RavenWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ export interface RavenWorkspace{
logo?: string
/** Only allow admins to create channels in the workspace : Check - If unchecked, any workspace member can create a channel */
only_admins_can_create_channels?: 0 | 1
/** Is Omni-Channel Workspace : Check */
is_omni_channel_workspace?: 0 | 1
}
6 changes: 6 additions & 0 deletions frontend/src/types/RavenChannelManagement/RavenChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,10 @@ export interface RavenChannel{
openai_thread_id?: string
/** Thread Bot : Link - Raven Bot */
thread_bot?: string
/** Is Customer : Check */
is_customer?: 0 | 1
/** Omni Channel Chat Provider : Link - Omni Channel Chat Provider */
omni_channel_chat_provider?: string
/** Raven User for Omni Channel : Link - Raven User */
omni_channel_raven_user?: string
}
3 changes: 2 additions & 1 deletion frontend/src/utils/channel/ChannelListProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export type UnreadCountData = UnreadChannelCountItem[]

export type ChannelListItem = Pick<RavenChannel, 'name' | 'channel_name' | 'type' |
'channel_description' | 'is_direct_message' | 'is_self_message' |
'is_archived' | 'creation' | 'owner' | 'last_message_details' | 'last_message_timestamp' | 'workspace' | 'pinned_messages_string'> & { member_id: string }
'is_archived' | 'creation' | 'owner' | 'last_message_details' | 'last_message_timestamp' | 'workspace' | 'pinned_messages_string' |
'omni_channel_chat_provider' | 'omni_channel_raven_user'> & { member_id: string, omni_channel_display_name?: string, omni_channel_provider?: string }

export interface DMChannelListItem extends ChannelListItem {
peer_user_id: string,
Expand Down
8 changes: 7 additions & 1 deletion raven/api/raven_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ def get_channel_list(hide_archived: bool = False):
"""
channel = frappe.qb.DocType("Raven Channel")
channel_member = frappe.qb.DocType("Raven Channel Member")

workspace_member = frappe.qb.DocType("Raven Workspace Member")
omni_provider = frappe.qb.DocType("Omni Channel Chat Provider")

query = (
frappe.qb.from_(channel)
Expand All @@ -64,6 +64,10 @@ def get_channel_list(hide_archived: bool = False):
channel.last_message_details,
channel.pinned_messages_string,
channel.workspace,
channel.omni_channel_chat_provider,
channel.omni_channel_raven_user,
omni_provider.display_name.as_("omni_channel_display_name"),
omni_provider.provider.as_("omni_channel_provider"),
channel_member.name.as_("member_id"),
)
.left_join(channel_member)
Expand All @@ -75,6 +79,8 @@ def get_channel_list(hide_archived: bool = False):
(channel.workspace == workspace_member.workspace)
& (workspace_member.user == frappe.session.user)
)
.left_join(omni_provider)
.on(channel.omni_channel_chat_provider == omni_provider.name)
.where(
((channel.is_direct_message == 1) & (channel_member.user_id == frappe.session.user))
| (
Expand Down
1 change: 1 addition & 0 deletions raven/api/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def get_list():
workspace.type,
workspace.description,
workspace.can_only_join_via_invite,
workspace.is_omni_channel_workspace,
workspace_member.name.as_("workspace_member_name"),
workspace_member.is_admin.as_("is_admin"),
)
Expand Down
3 changes: 2 additions & 1 deletion raven/modules.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ Raven Messaging
Raven Channel Management
Raven Bot
Raven Integrations
Raven AI
Raven AI
Omni-Channel Chat
Loading
Loading