@@ -51,19 +51,39 @@ import {
5151 channelListQueryOptions ,
5252} from "@/lib/channels/queries" ;
5353import { useChannelEvents } from "@/lib/channels/use-channel-events" ;
54+ import { externalThreadListQueryOptions } from "@/lib/external/queries" ;
5455import { appConfig } from "@/lib/generated/application-config" ;
5556import { EASE_OUT , ENTRANCE_SECONDS } from "@/lib/motion" ;
5657import { relativeTime } from "@/lib/relative-time" ;
5758import { Button } from "../ui/button" ;
5859import { Empty , EmptyDescription , EmptyHeader , EmptyTitle } from "../ui/empty" ;
5960import { Channel } from "./channel" ;
61+ import {
62+ conversationRoster ,
63+ matchingRoster ,
64+ type RosterSourceStatus ,
65+ rosterKey ,
66+ shouldShowEmptyRoster ,
67+ shouldShowSearchEmpty ,
68+ type SidebarRosterRow ,
69+ } from "./roster" ;
70+ import { SlackChannel , SlackRosterProblem } from "./slack-channel" ;
6071
6172const appLinkOptions = { to : "/" } satisfies LinkOptions ;
6273const adminLinkOptions = { to : "/admin" } satisfies LinkOptions ;
6374const settingsLinkOptions = { to : "/settings" } satisfies LinkOptions ;
6475
6576const userMenuItemClassName = "gap-2 px-2 py-1.5" ;
6677
78+ function rosterSourceStatus ( query : {
79+ isError : boolean ;
80+ isSuccess : boolean ;
81+ } ) : RosterSourceStatus {
82+ if ( query . isSuccess ) return "success" ;
83+ if ( query . isError ) return "error" ;
84+ return "pending" ;
85+ }
86+
6787function UserAvatar ( ) {
6888 const { data : currentUser } = useQuery ( currentUserQueryOptions ( ) ) ;
6989 const initials =
@@ -86,36 +106,6 @@ function UserAvatar() {
86106 */
87107const MAX_ANIMATED_ROWS = 60 ;
88108
89- /**
90- * The roster, narrowed to what the person typed.
91- *
92- * Matches the channel's name, its summary, and the last message, because those are the things the
93- * row can actually show — searching against something invisible returns results a person cannot
94- * account for. The last message is included because it is still what the second line draws until the
95- * conversation has been named. Message history beyond that line is not here to search: it lives in
96- * the thread store, and reaching for it is a server endpoint rather than a filter.
97- *
98- * An empty query returns the input array unchanged rather than a copy, so typing and clearing does
99- * not hand `AnimatePresence` a new array identity and restage the whole list.
100- */
101- export function matchingChannels (
102- channels : ChannelSummary [ ] | undefined ,
103- query : string ,
104- ) : ChannelSummary [ ] {
105- if ( ! channels ) {
106- return [ ] ;
107- }
108- const needle = query . trim ( ) . toLowerCase ( ) ;
109- if ( ! needle ) {
110- return channels ;
111- }
112- return channels . filter ( ( channel ) =>
113- [ channel . name , channel . summary , channel . lastMessage ] . some ( ( field ) =>
114- field ?. toLowerCase ( ) . includes ( needle ) ,
115- ) ,
116- ) ;
117- }
118-
119109/**
120110 * Pinned channels first, everything else after, newest activity first within each group.
121111 *
@@ -162,9 +152,11 @@ export function isUnread(
162152 * moves under the cursor.
163153 */
164154function ChannelRow ( {
155+ animateVisibility,
165156 channel,
166157 animateOrder,
167158} : {
159+ animateVisibility : boolean ;
168160 channel : ChannelSummary ;
169161 animateOrder : boolean ;
170162} ) {
@@ -178,12 +170,18 @@ function ChannelRow({
178170 } ) ;
179171 return (
180172 < motion . div
181- animate = { { opacity : 1 , transform : "translateY(0px)" } }
182- initial = { {
183- opacity : 0 ,
184- transform : shouldReduceMotion ? "none" : "translateY(-8px)" ,
185- } }
186- exit = { { opacity : 0 } }
173+ animate = {
174+ animateVisibility ? { opacity : 1 , transform : "translateY(0px)" } : false
175+ }
176+ initial = {
177+ animateVisibility
178+ ? {
179+ opacity : 0 ,
180+ transform : shouldReduceMotion ? "none" : "translateY(-8px)" ,
181+ }
182+ : false
183+ }
184+ exit = { animateVisibility ? { opacity : 0 } : undefined }
187185 layout = { animateOrder && ! shouldReduceMotion ? "position" : false }
188186 transition = { { duration : ENTRANCE_SECONDS , ease : EASE_OUT } }
189187 >
@@ -206,25 +204,68 @@ function ChannelRow({
206204 ) ;
207205}
208206
207+ function SlackRow ( {
208+ animateVisibility,
209+ animateOrder,
210+ thread,
211+ } : {
212+ animateVisibility : boolean ;
213+ animateOrder : boolean ;
214+ thread : SidebarRosterRow & { kind : "slack" } ;
215+ } ) {
216+ const shouldReduceMotion = useReducedMotion ( ) ;
217+ return (
218+ < motion . div
219+ animate = {
220+ animateVisibility ? { opacity : 1 , transform : "translateY(0px)" } : false
221+ }
222+ initial = {
223+ animateVisibility
224+ ? {
225+ opacity : 0 ,
226+ transform : shouldReduceMotion ? "none" : "translateY(-8px)" ,
227+ }
228+ : false
229+ }
230+ exit = { animateVisibility ? { opacity : 0 } : undefined }
231+ layout = { animateOrder && ! shouldReduceMotion ? "position" : false }
232+ transition = { { duration : ENTRANCE_SECONDS , ease : EASE_OUT } }
233+ >
234+ < SlackChannel
235+ lastMessageAt = {
236+ thread . thread . lastMessageAt
237+ ? relativeTime ( thread . thread . lastMessageAt )
238+ : undefined
239+ }
240+ thread = { thread . thread }
241+ />
242+ </ motion . div >
243+ ) ;
244+ }
245+
209246export function AppSidebar ( { ...props } : React . ComponentProps < typeof Sidebar > ) {
210247 const { data : currentUser } = useQuery ( currentUserQueryOptions ( ) ) ;
211248 const queryClient = useQueryClient ( ) ;
212249 const navigate = useNavigate ( ) ;
213250 const signOut = useMutation ( signOutMutationOptions ( queryClient ) ) ;
214251 const channels = useInfiniteQuery ( channelListQueryOptions ( ) ) ;
252+ const slackThreads = useInfiniteQuery ( externalThreadListQueryOptions ( ) ) ;
215253 // One socket for the app, opened where the roster is kept live.
216254 useChannelEvents ( ) ;
217255 const [ search , setSearch ] = useState ( "" ) ;
218256 const searching = search . trim ( ) . length > 0 ;
219- const visibleChannels = pinnedFirst ( matchingChannels ( channels . data , search ) ) ;
257+ const roster = conversationRoster ( channels . data , slackThreads . data ) ;
258+ const visibleRoster = matchingRoster ( roster , search ) ;
259+ const channelStatus = rosterSourceStatus ( channels ) ;
260+ const slackThreadStatus = rosterSourceStatus ( slackThreads ) ;
220261 /*
221262 * FILTERING DOES NOT ANIMATE. Rows exit and relayout on every keystroke otherwise, which is a
222263 * list thrashing under somebody who is still typing — and the moving target is the very thing
223264 * they are trying to read. Order animation is for a channel that was just spoken in, which is
224265 * occasional; this is not.
225266 */
226- const animateOrder =
227- ! searching && ( channels . data ?. length ?? 0 ) <= MAX_ANIMATED_ROWS ;
267+ const animateOrder = ! searching && roster . length <= MAX_ANIMATED_ROWS ;
268+ const animateVisibility = ! searching ;
228269
229270 const handleSignOut = async ( ) => {
230271 await signOut . mutateAsync ( ) ;
@@ -268,7 +309,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
268309 < SidebarMenuItem >
269310 < InputGroup className = "bg-background text-sm rounded-lg h-9" >
270311 < InputGroupInput
271- aria-label = "Search channels "
312+ aria-label = "Search conversations "
272313 onChange = { ( event ) => setSearch ( event . target . value ) }
273314 placeholder = "Search..."
274315 value = { search }
@@ -285,7 +326,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
285326 * the box has to say so and quote it back — told "you don't have channels yet" while
286327 * holding a typo, a person reads their conversations as gone.
287328 */ }
288- { searching && visibleChannels . length === 0 ? (
329+ { shouldShowSearchEmpty (
330+ visibleRoster ,
331+ search ,
332+ channelStatus ,
333+ slackThreadStatus ,
334+ ) ? (
289335 < div className = "py-4" >
290336 < Empty className = "border border-dashed min-h-[40dvh]" >
291337 < EmptyHeader >
@@ -298,7 +344,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
298344 </ Empty >
299345 </ div >
300346 ) : null }
301- { ! searching && channels . data ?. length === 0 ? (
347+ { ! searching &&
348+ shouldShowEmptyRoster (
349+ channels . data ,
350+ slackThreads . data ,
351+ channels . isSuccess ,
352+ slackThreads . isSuccess ,
353+ ) ? (
302354 < div className = "py-4" >
303355 < Empty className = "border border-dashed min-h-[40dvh]" >
304356 < EmptyHeader >
@@ -311,14 +363,32 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
311363 </ Empty >
312364 </ div >
313365 ) : null }
366+ { slackThreads . isError ? (
367+ < SlackRosterProblem
368+ isRetrying = { slackThreads . isFetching }
369+ onRetry = { ( ) => {
370+ void slackThreads . refetch ( ) ;
371+ } }
372+ />
373+ ) : null }
314374 < AnimatePresence initial = { false } >
315- { visibleChannels . map ( ( channel ) => (
316- < ChannelRow
317- key = { channel . id }
318- animateOrder = { animateOrder }
319- channel = { channel }
320- />
321- ) ) }
375+ { visibleRoster . map ( ( row ) =>
376+ row . kind === "openbot" ? (
377+ < ChannelRow
378+ key = { rosterKey ( row ) }
379+ animateVisibility = { animateVisibility }
380+ animateOrder = { animateOrder }
381+ channel = { row . channel }
382+ />
383+ ) : (
384+ < SlackRow
385+ key = { rosterKey ( row ) }
386+ animateVisibility = { animateVisibility }
387+ animateOrder = { animateOrder }
388+ thread = { row }
389+ />
390+ ) ,
391+ ) }
322392 </ AnimatePresence >
323393 </ SidebarGroup >
324394 </ SidebarMenu >
0 commit comments