Skip to content

Commit

Permalink
feat(ui): add notifications box (#3467)
Browse files Browse the repository at this point in the history
* feat(ui): add notifications box

* [autofix.ci] apply automated fixes

* update: integrate

* update: cache manage

* [autofix.ci] apply automated fixes

* update: display

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
  • Loading branch information
liangfung and autofix-ci[bot] authored Dec 16, 2024
1 parent b7ec1fe commit fb02fec
Show file tree
Hide file tree
Showing 8 changed files with 314 additions and 3 deletions.
2 changes: 2 additions & 0 deletions ee/tabby-ui/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { cn } from '@/lib/utils'
import { ScrollArea } from '@/components/ui/scroll-area'
import { ClientOnly } from '@/components/client-only'
import { BANNER_HEIGHT, useShowDemoBanner } from '@/components/demo-banner'
import { NotificationBox } from '@/components/notification-box'
import SlackDialog from '@/components/slack-dialog'
import TextAreaSearch from '@/components/textarea-search'
import { ThemeToggle } from '@/components/theme-toggle'
Expand Down Expand Up @@ -110,6 +111,7 @@ function MainPanel() {
<ClientOnly>
<ThemeToggle />
</ClientOnly>
<NotificationBox />
<UserPanel showHome={false} showSetting>
<MyAvatar className="h-10 w-10 border" />
</UserPanel>
Expand Down
4 changes: 3 additions & 1 deletion ee/tabby-ui/app/search/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
IconTrash
} from '@/components/ui/icons'
import { ClientOnly } from '@/components/client-only'
import { NotificationBox } from '@/components/notification-box'
import { ThemeToggle } from '@/components/theme-toggle'
import { MyAvatar } from '@/components/user-avatar'
import UserPanel from '@/components/user-panel'
Expand Down Expand Up @@ -136,8 +137,9 @@ export function Header({ threadIdFromURL, streamingDone }: HeaderProps) {
</AlertDialog>
)}
<ClientOnly>
<ThemeToggle className="mr-4" />
<ThemeToggle />
</ClientOnly>
<NotificationBox className="mr-4" />
<UserPanel
showHome={false}
showSetting
Expand Down
2 changes: 2 additions & 0 deletions ee/tabby-ui/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { buttonVariants } from '@/components/ui/button'
import { IconNotice } from '@/components/ui/icons'

import { ClientOnly } from './client-only'
import { NotificationBox } from './notification-box'
import { ThemeToggle } from './theme-toggle'
import { SidebarTrigger } from './ui/sidebar'
import { MyAvatar } from './user-avatar'
Expand Down Expand Up @@ -43,6 +44,7 @@ export function Header() {
<ClientOnly>
<ThemeToggle />
</ClientOnly>
<NotificationBox />
<UserPanel>
<MyAvatar className="h-10 w-10 border" />
</UserPanel>
Expand Down
249 changes: 249 additions & 0 deletions ee/tabby-ui/components/notification-box.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
'use client'

import { HTMLAttributes, useMemo } from 'react'
import { TabsContent } from '@radix-ui/react-tabs'
import moment from 'moment'
import { useQuery } from 'urql'

import { graphql } from '@/lib/gql/generates'
import { NotificationsQuery } from '@/lib/gql/generates/graphql'
import { useMutation } from '@/lib/tabby/gql'
import { notificationsQuery } from '@/lib/tabby/query'
import { ArrayElementType } from '@/lib/types'
import { cn } from '@/lib/utils'

import LoadingWrapper from './loading-wrapper'
import { ListSkeleton } from './skeleton'
import { Button } from './ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from './ui/dropdown-menu'
import { IconArrowRight, IconBell, IconCheck } from './ui/icons'
import { Separator } from './ui/separator'
import { Tabs, TabsList, TabsTrigger } from './ui/tabs'

interface Props extends HTMLAttributes<HTMLDivElement> {}

const markNotificationsReadMutation = graphql(/* GraphQL */ `
mutation markNotificationsRead($notificationId: ID) {
markNotificationsRead(notificationId: $notificationId)
}
`)

export function NotificationBox({ className, ...rest }: Props) {
const [{ data, fetching }] = useQuery({
query: notificationsQuery
})

const notifications = useMemo(() => {
return data?.notifications.slice().reverse()
}, [data?.notifications])

const unreadNotifications = useMemo(() => {
return notifications?.filter(o => !o.read) ?? []
}, [notifications])
const hasUnreadNotification = unreadNotifications.length > 0

const markNotificationsRead = useMutation(markNotificationsReadMutation)
const onClickMarkAllRead = () => {
markNotificationsRead()
}

return (
<div className={cn(className)} {...rest}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<IconBell />
{hasUnreadNotification && (
<div className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-red-400"></div>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className="flex w-96 flex-col overflow-hidden p-0"
style={{ maxHeight: '60vh' }}
>
<div className="flex items-center justify-between px-4 py-2">
<div className="text-sm font-medium">Nofitications</div>
<Button
size="sm"
className="h-6 py-1 text-xs"
onClick={onClickMarkAllRead}
disabled={!hasUnreadNotification}
>
Mark all as read
</Button>
</div>
<Separator />
<Tabs
className="relative my-2 flex-1 overflow-y-auto px-4"
defaultValue="unread"
>
<TabsList className="sticky top-0 z-10 grid w-full grid-cols-2">
<TabsTrigger value="unread">Unread</TabsTrigger>
<TabsTrigger value="all">All</TabsTrigger>
</TabsList>
<TabsContent value="unread" className="mt-4">
<LoadingWrapper loading={fetching} fallback={<ListSkeleton />}>
<NotificationList
type="unread"
notifications={unreadNotifications}
/>
</LoadingWrapper>
</TabsContent>
<TabsContent value="all" className="mt-4">
<LoadingWrapper loading={fetching} fallback={<ListSkeleton />}>
<NotificationList type="all" notifications={notifications} />
</LoadingWrapper>
</TabsContent>
</Tabs>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

function NotificationList({
notifications,
type
}: {
notifications: NotificationsQuery['notifications'] | undefined
type: 'unread' | 'all'
}) {
const len = notifications?.length ?? 0

if (!len) {
return (
<div className="my-4 text-center text-sm text-muted-foreground">
{type === 'unread' ? 'No unread notifications' : 'No notifications'}
</div>
)
}

return (
<div className="space-y-2">
{notifications?.map((item, index) => {
return (
<div key={item.id}>
<NotificationItem data={item} />
<Separator
className={cn('my-3', {
hidden: index === len - 1
})}
/>
</div>
)
})}
</div>
)
}

interface NotificationItemProps extends HTMLAttributes<HTMLDivElement> {
data: ArrayElementType<NotificationsQuery['notifications']>
}

function NotificationItem({ data }: NotificationItemProps) {
const { type, title, content } = resolveNotification(data.content)

const markNotificationsRead = useMutation(markNotificationsReadMutation)

const onClickMarkRead = () => {
markNotificationsRead({
notificationId: data.id
})
}

const onAction = () => {
onClickMarkRead()

if (type === 'license_will_expire') {
return window.open('/settings/subscription')
}
}

return (
<div className="group space-y-1.5">
<div className="space-y-1.5" onClick={onAction}>
<div className="flex cursor-pointer items-center gap-1.5 overflow-hidden text-sm font-medium">
{!data.read && (
<span className="h-2 w-2 shrink-0 rounded-full bg-red-400"></span>
)}
<span className="flex-1 truncate group-hover:opacity-70">
{title}
</span>
</div>
<div className="cursor-pointer whitespace-pre-wrap break-words text-sm text-muted-foreground group-hover:opacity-70">
{content}
</div>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="text-muted-foreground">
{formatNotificationTime(data.createdAt)}
</span>
<div className="flex items-center gap-1.5">
<Button
variant="link"
className="flex h-auto items-center gap-0.5 p-1 text-xs text-muted-foreground"
onClick={onAction}
>
<IconArrowRight className="h-3 w-3" />
Detail
</Button>
{!data.read && (
<Button
variant="link"
className="flex h-auto items-center gap-0.5 p-1 text-xs text-muted-foreground"
onClick={onClickMarkRead}
>
<IconCheck className="h-3 w-3" />
Mark as read
</Button>
)}
</div>
</div>
</div>
)
}

function resolveNotification(content: string) {
// use first line as title
const title = content.split('\n')[0]
const _content = content.split('\n').slice(1).join('\n')

if (content.startsWith('Your license will expire')) {
return {
type: 'license_will_expire',
title,
content: _content
}
}

return {
type: '',
title,
content: _content
}
}

// Nov 21, 2022, 7:03 AM
// Nov 21, 7:03 AM
function formatNotificationTime(time: string) {
const targetTime = moment(time)

if (targetTime.isBefore(moment().subtract(1, 'year'))) {
const timeText = targetTime.format('MMM D, YYYY, h:mm A')
return timeText
}

if (targetTime.isBefore(moment().subtract(1, 'month'))) {
const timeText = targetTime.format('MMM D, hh:mm A')
return `${timeText}`
}

return `${targetTime.fromNow()}`
}
8 changes: 7 additions & 1 deletion ee/tabby-ui/components/ui/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { LetterCaseCapitalizeIcon } from '@radix-ui/react-icons'
import {
AlignJustify,
AtSign,
Bell,
Blocks,
BookOpenText,
Box,
Expand Down Expand Up @@ -1745,6 +1746,10 @@ function IconFileSearch2({
return <FileSearch2 className={cn('h-4 w-4', className)} {...props} />
}

function IconBell({ className, ...props }: React.ComponentProps<typeof Bell>) {
return <Bell className={cn('h-4 w-4', className)} {...props} />
}

export {
IconEdit,
IconNextChat,
Expand Down Expand Up @@ -1857,5 +1862,6 @@ export {
IconGitPullRequest,
IconGitMerge,
IconSquareChevronRight,
IconFileSearch2
IconFileSearch2,
IconBell
}
2 changes: 1 addition & 1 deletion ee/tabby-ui/components/user-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export default function UserPanel({
}

return (
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
Expand Down
39 changes: 39 additions & 0 deletions ee/tabby-ui/lib/tabby/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
ListIntegrationsQueryVariables,
ListInvitationsQueryVariables,
ListThreadsQueryVariables,
NotificationsQueryVariables,
SourceIdAccessPoliciesQueryVariables,
UpsertUserGroupMembershipInput
} from '../gql/generates/graphql'
Expand All @@ -35,6 +36,7 @@ import {
listRepositories,
listSourceIdAccessPolicies,
listThreads,
notificationsQuery,
userGroupsQuery
} from './query'
import {
Expand Down Expand Up @@ -421,6 +423,43 @@ const client = new Client({
cache.invalidate(key, field.fieldName, field.arguments)
})
}
},
markNotificationsRead(result, args, cache) {
if (result.markNotificationsRead) {
cache
.inspectFields('Query')
.filter(field => field.fieldName === 'notifications')
.forEach(field => {
cache.updateQuery(
{
query: notificationsQuery,
variables: field.arguments as NotificationsQueryVariables
},
data => {
if (data?.notifications) {
const isMarkAllAsRead = !args.notificationId
data.notifications = data.notifications.map(item => {
if (isMarkAllAsRead) {
return {
...item,
read: true
}
} else {
if (item.id === args.notificationId) {
return {
...item,
read: true
}
}
return item
}
})
}
return data
}
)
})
}
}
}
},
Expand Down
Loading

0 comments on commit fb02fec

Please sign in to comment.