first attempt of createTimeline in TimelinePanel
- known bug: the paging is failed
This commit is contained in:
		
							parent
							
								
									0b4eb761ad
								
							
						
					
					
						commit
						8f2b0cb489
					
				
					 4 changed files with 202 additions and 64 deletions
				
			
		
							
								
								
									
										
											BIN
										
									
								
								bun.lockb
									
										
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								bun.lockb
									
										
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							|  | @ -33,6 +33,7 @@ | ||||||
|     "@solid-primitives/event-listener": "^2.3.3", |     "@solid-primitives/event-listener": "^2.3.3", | ||||||
|     "@solid-primitives/i18n": "^2.1.1", |     "@solid-primitives/i18n": "^2.1.1", | ||||||
|     "@solid-primitives/intersection-observer": "^2.1.6", |     "@solid-primitives/intersection-observer": "^2.1.6", | ||||||
|  |     "@solid-primitives/map": "^0.4.13", | ||||||
|     "@solid-primitives/resize-observer": "^2.0.26", |     "@solid-primitives/resize-observer": "^2.0.26", | ||||||
|     "@solidjs/router": "^0.14.5", |     "@solidjs/router": "^0.14.5", | ||||||
|     "@suid/icons-material": "^0.8.0", |     "@suid/icons-material": "^0.8.0", | ||||||
|  |  | ||||||
|  | @ -1,5 +1,14 @@ | ||||||
|  | import { ReactiveMap } from "@solid-primitives/map"; | ||||||
| import { type mastodon } from "masto"; | import { type mastodon } from "masto"; | ||||||
| import { Accessor, catchError, createEffect, createResource } from "solid-js"; | import { | ||||||
|  |   Accessor, | ||||||
|  |   batch, | ||||||
|  |   catchError, | ||||||
|  |   createEffect, | ||||||
|  |   createResource, | ||||||
|  |   untrack, | ||||||
|  |   type ResourceFetcherInfo, | ||||||
|  | } from "solid-js"; | ||||||
| import { createStore } from "solid-js/store"; | import { createStore } from "solid-js/store"; | ||||||
| 
 | 
 | ||||||
| type TimelineFetchTips = { | type TimelineFetchTips = { | ||||||
|  | @ -164,6 +173,148 @@ export function createTimelineSnapshot( | ||||||
|   ] as const; |   ] as const; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function createTimeline(timeline: Accessor<Timeline>) { | export type TimelineFetchDirection = mastodon.Direction; | ||||||
|   // TODO
 | 
 | ||||||
|  | export type TimelineChunk = { | ||||||
|  |   pager: mastodon.Paginator<mastodon.v1.Status[], unknown>; | ||||||
|  |   chunk: readonly mastodon.v1.Status[]; | ||||||
|  |   done?: boolean; | ||||||
|  |   direction: TimelineFetchDirection; | ||||||
|  |   limit: number; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | function checkOrCreatePager( | ||||||
|  |   timeline: Timeline, | ||||||
|  |   limit: number, | ||||||
|  |   lastPager: TimelineChunk["pager"] | undefined, | ||||||
|  |   newDirection: TimelineFetchDirection, | ||||||
|  | ) { | ||||||
|  |   if (!lastPager) { | ||||||
|  |     return timeline.list({  }).setDirection(newDirection); | ||||||
|  |   } else { | ||||||
|  |     let pager = lastPager; | ||||||
|  |     if (pager.getDirection() !== newDirection) { | ||||||
|  |       pager = pager.setDirection(newDirection); | ||||||
|  |     } | ||||||
|  |     return pager; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type TreeNode<T> = { | ||||||
|  |   parent?: TreeNode<T>; | ||||||
|  |   value: T; | ||||||
|  |   children?: TreeNode<T>[]; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** Collect the path of a node for the root. | ||||||
|  |  * The first element is the node itself, the last element is the root. | ||||||
|  |  */ | ||||||
|  | function collectPath<T>(node: TreeNode<T>) { | ||||||
|  |   const path = [node] as TreeNode<T>[]; | ||||||
|  |   let current = node; | ||||||
|  |   while (current.parent) { | ||||||
|  |     path.push(current.parent); | ||||||
|  |     current = current.parent; | ||||||
|  |   } | ||||||
|  |   return path; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function createTimeline( | ||||||
|  |   timeline: Accessor<Timeline>, | ||||||
|  |   limit: Accessor<number>, | ||||||
|  | ) { | ||||||
|  |   const lookup = new ReactiveMap<string, TreeNode<mastodon.v1.Status>>(); | ||||||
|  |   const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]); | ||||||
|  | 
 | ||||||
|  |   const [chunk, { refetch }] = createResource( | ||||||
|  |     () => [timeline(), limit()] as const, | ||||||
|  |     async ( | ||||||
|  |       [tl, limit], | ||||||
|  |       info: ResourceFetcherInfo< | ||||||
|  |         Readonly<TimelineChunk>, | ||||||
|  |         TimelineFetchDirection | ||||||
|  |       >, | ||||||
|  |     ) => { | ||||||
|  |       const direction = | ||||||
|  |         typeof info.refetching === "boolean" ? "prev" : info.refetching; | ||||||
|  |       const rebuildTimeline = limit !== info.value?.limit; | ||||||
|  |       const pager = rebuildTimeline | ||||||
|  |         ? checkOrCreatePager(tl, limit, undefined, direction) | ||||||
|  |         : checkOrCreatePager(tl, limit, info.value?.pager, direction); | ||||||
|  |       if (rebuildTimeline) { | ||||||
|  |         lookup.clear(); | ||||||
|  |         setThreads([]); | ||||||
|  |       } | ||||||
|  |       const posts = await pager.next(); | ||||||
|  |       return { | ||||||
|  |         pager, | ||||||
|  |         chunk: posts.value ?? [], | ||||||
|  |         done: posts.done, | ||||||
|  |         direction, | ||||||
|  |         limit, | ||||||
|  |       }; | ||||||
|  |     }, | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   createEffect(() => { | ||||||
|  |     const chk = catchError(chunk, (e) => console.error(e)); | ||||||
|  |     if (!chk) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     console.debug("fetched chunk", chk); | ||||||
|  | 
 | ||||||
|  |     batch(() => { | ||||||
|  |       for (const status of chk.chunk) { | ||||||
|  |         lookup.set(status.id, { | ||||||
|  |           value: status, | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       for (const status of chk.chunk) { | ||||||
|  |         const node = lookup.get(status.id)!; | ||||||
|  |         if (status.inReplyToId) { | ||||||
|  |           const parent = lookup.get(status.inReplyToId); | ||||||
|  |           if (parent) { | ||||||
|  |             const children = parent.children ?? []; | ||||||
|  |             if (!children.find((x) => x.value.id == status.id)) { | ||||||
|  |               children.push(node); | ||||||
|  |             } | ||||||
|  |             parent.children = children; | ||||||
|  |             node.parent = parent; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       if (chk.direction === "next") { | ||||||
|  |         setThreads((threads) => [...threads, ...chk.chunk.map((x) => x.id)]); | ||||||
|  |       } else if (chk.direction === "prev") { | ||||||
|  |         setThreads((threads) => [...chk.chunk.map((x) => x.id), ...threads]); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       setThreads((threads) => | ||||||
|  |         threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0), | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   return [ | ||||||
|  |     { | ||||||
|  |       list: threads, | ||||||
|  |       get(id: string) { | ||||||
|  |         return lookup.get(id); | ||||||
|  |       }, | ||||||
|  |       getPath(id: string) { | ||||||
|  |         const node = lookup.get(id); | ||||||
|  |         if (!node) return; | ||||||
|  |         return collectPath(node); | ||||||
|  |       }, | ||||||
|  |       set(id: string, value: mastodon.v1.Status) { | ||||||
|  |         const node = untrack(() => lookup.get(id)); | ||||||
|  |         if (!node) return; | ||||||
|  |         node.value = value; | ||||||
|  |         lookup.set(id, node); | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     chunk, | ||||||
|  |     { refetch }, | ||||||
|  |   ] as const; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -10,19 +10,16 @@ import { | ||||||
|   ErrorBoundary, |   ErrorBoundary, | ||||||
| } from "solid-js"; | } from "solid-js"; | ||||||
| import { type mastodon } from "masto"; | import { type mastodon } from "masto"; | ||||||
| import { | import { Button, LinearProgress } from "@suid/material"; | ||||||
|   Button, | import { createTimeline } from "../masto/timelines"; | ||||||
|   LinearProgress, |  | ||||||
| } from "@suid/material"; |  | ||||||
| import TootThread from "./TootThread.js"; |  | ||||||
| import { useTimeline } from "../masto/timelines"; |  | ||||||
| import { vibrate } from "../platform/hardware"; | import { vibrate } from "../platform/hardware"; | ||||||
| import PullDownToRefresh from "./PullDownToRefresh"; | import PullDownToRefresh from "./PullDownToRefresh"; | ||||||
| import TootComposer from "./TootComposer"; | import TootComposer from "./TootComposer"; | ||||||
|  | import Thread from "./Thread.jsx"; | ||||||
| 
 | 
 | ||||||
| const TimelinePanel: Component<{ | const TimelinePanel: Component<{ | ||||||
|   client: mastodon.rest.Client; |   client: mastodon.rest.Client; | ||||||
|   name: "home" | "public" | "trends"; |   name: "home" | "public"; | ||||||
|   prefetch?: boolean; |   prefetch?: boolean; | ||||||
|   fullRefetch?: number; |   fullRefetch?: number; | ||||||
| 
 | 
 | ||||||
|  | @ -33,70 +30,56 @@ const TimelinePanel: Component<{ | ||||||
|   ) => void; |   ) => void; | ||||||
| }> = (props) => { | }> = (props) => { | ||||||
|   const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>(); |   const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>(); | ||||||
|   const [ | 
 | ||||||
|     timeline, |   const [timeline, snapshot, { refetch: refetchTimeline }] = createTimeline( | ||||||
|     snapshot, |     () => props.client.v1.timelines[props.name], | ||||||
|     { refetch: refetchTimeline, mutate: mutateTimeline }, |     () => 20, | ||||||
|   ] = useTimeline( |  | ||||||
|     () => |  | ||||||
|       props.name !== "trends" |  | ||||||
|         ? props.client.v1.timelines[props.name] |  | ||||||
|         : props.client.v1.trends.statuses, |  | ||||||
|     { fullRefresh: props.fullRefetch }, |  | ||||||
|   ); |   ); | ||||||
|   const [expandedThreadId, setExpandedThreadId] = createSignal<string>(); |   const [expandedThreadId, setExpandedThreadId] = createSignal<string>(); | ||||||
|   const [typing, setTyping] = createSignal(false); |   const [typing, setTyping] = createSignal(false); | ||||||
| 
 | 
 | ||||||
|   const tlEndObserver = new IntersectionObserver(() => { |   const tlEndObserver = new IntersectionObserver(() => { | ||||||
|     if (untrack(() => props.prefetch) && !snapshot.loading) |     if (untrack(() => props.prefetch) && !snapshot.loading) | ||||||
|       refetchTimeline({ direction: "old" }); |       refetchTimeline("next"); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   onCleanup(() => tlEndObserver.disconnect()); |   onCleanup(() => tlEndObserver.disconnect()); | ||||||
| 
 | 
 | ||||||
|   const onBookmark = async ( |   const onBookmark = async ( | ||||||
|     index: number, |  | ||||||
|     client: mastodon.rest.Client, |     client: mastodon.rest.Client, | ||||||
|     status: mastodon.v1.Status, |     status: mastodon.v1.Status, | ||||||
|   ) => { |   ) => { | ||||||
|     const result = await (status.bookmarked |     const result = await (status.bookmarked | ||||||
|       ? client.v1.statuses.$select(status.id).unbookmark() |       ? client.v1.statuses.$select(status.id).unbookmark() | ||||||
|       : client.v1.statuses.$select(status.id).bookmark()); |       : client.v1.statuses.$select(status.id).bookmark()); | ||||||
|     mutateTimeline((o) => { |     timeline.set(result.id, result); | ||||||
|       o[index] = result; |  | ||||||
|       return o; |  | ||||||
|     }); |  | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const onBoost = async ( |   const onBoost = async ( | ||||||
|     index: number, |  | ||||||
|     client: mastodon.rest.Client, |     client: mastodon.rest.Client, | ||||||
|     status: mastodon.v1.Status, |     status: mastodon.v1.Status, | ||||||
|   ) => { |   ) => { | ||||||
|     const reblogged = status.reblog |  | ||||||
|       ? status.reblog.reblogged |  | ||||||
|       : status.reblogged; |  | ||||||
|     vibrate(50); |     vibrate(50); | ||||||
|     mutateTimeline(index, (x) => { |     const rootStatus = status.reblog ? status.reblog : status; | ||||||
|       if (x.reblog) { |     const reblogged = rootStatus.reblogged; | ||||||
|         x.reblog = { ...x.reblog, reblogged: !reblogged }; |     if (status.reblog) { | ||||||
|         return Object.assign({}, x); |       status.reblog = { ...status.reblog, reblogged: !reblogged }; | ||||||
|       } else { |       timeline.set(status.id, status); | ||||||
|         return Object.assign({}, x, { |     } else { | ||||||
|  |       timeline.set( | ||||||
|  |         status.id, | ||||||
|  |         Object.assign(status, { | ||||||
|           reblogged: !reblogged, |           reblogged: !reblogged, | ||||||
|         }); |         }), | ||||||
|       } |       ); | ||||||
|     }); |     } | ||||||
|     const result = reblogged |     const result = reblogged | ||||||
|       ? await client.v1.statuses.$select(status.id).unreblog() |       ? await client.v1.statuses.$select(status.id).unreblog() | ||||||
|       : (await client.v1.statuses.$select(status.id).reblog()).reblog!; |       : (await client.v1.statuses.$select(status.id).reblog()).reblog!; | ||||||
|     mutateTimeline((o) => { |     timeline.set( | ||||||
|       Object.assign(o[index].reblog ?? o[index], { |       status.id, | ||||||
|         reblogged: result.reblogged, |       Object.assign(status.reblog ?? status, result.reblog), | ||||||
|         reblogsCount: result.reblogsCount, |     ); | ||||||
|       }); |  | ||||||
|       return o; |  | ||||||
|     }); |  | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|  | @ -108,7 +91,7 @@ const TimelinePanel: Component<{ | ||||||
|       <PullDownToRefresh |       <PullDownToRefresh | ||||||
|         linkedElement={scrollLinked()} |         linkedElement={scrollLinked()} | ||||||
|         loading={snapshot.loading} |         loading={snapshot.loading} | ||||||
|         onRefresh={() => refetchTimeline({ direction: "new" })} |         onRefresh={() => refetchTimeline("prev")} | ||||||
|       /> |       /> | ||||||
|       <div |       <div | ||||||
|         ref={(e) => |         ref={(e) => | ||||||
|  | @ -125,29 +108,32 @@ const TimelinePanel: Component<{ | ||||||
|             isTyping={typing()} |             isTyping={typing()} | ||||||
|             onTypingChange={setTyping} |             onTypingChange={setTyping} | ||||||
|             client={props.client} |             client={props.client} | ||||||
|             onSent={() => refetchTimeline({ direction: "new" })} |             onSent={() => refetchTimeline("prev")} | ||||||
|           /> |           /> | ||||||
|         </Show> |         </Show> | ||||||
|         <For each={timeline}> |         <For each={timeline.list}> | ||||||
|           {(item, index) => { |           {(itemId, index) => { | ||||||
|             let element: HTMLElement | undefined; |             let element: HTMLElement | undefined; | ||||||
|  |             const path = timeline.getPath(itemId)!; | ||||||
|  |             const toots = path.reverse().map((x) => x.value); | ||||||
|  | 
 | ||||||
|             return ( |             return ( | ||||||
|               <TootThread |               <Thread | ||||||
|                 ref={element} |                 ref={element} | ||||||
|                 status={item} |                 toots={toots} | ||||||
|                 onBoost={(...args) => onBoost(index(), ...args)} |                 onBoost={onBoost} | ||||||
|                 onBookmark={(...args) => onBookmark(index(), ...args)} |                 onBookmark={onBookmark} | ||||||
|                 onReply={(client, status) => |                 onReply={(client, status) => | ||||||
|                   props.openFullScreenToot(status, element, true) |                   props.openFullScreenToot(status, element, true) | ||||||
|                 } |                 } | ||||||
|                 client={props.client} |                 client={props.client} | ||||||
|                 expanded={item.id === expandedThreadId() ? 1 : 0} |                 isExpended={(status) => status.id === expandedThreadId()} | ||||||
|                 onExpandChange={(x) => { |                 onItemClick={(status) => { | ||||||
|                   setTyping(false) |                   setTyping(false); | ||||||
|                   if (item.id !== expandedThreadId()) { |                   if (status.id !== expandedThreadId()) { | ||||||
|                     setExpandedThreadId((x) => (x ? undefined : item.id)); |                     setExpandedThreadId((x) => (x ? undefined : status.id)); | ||||||
|                   } else if (x === 2) { |                   } else { | ||||||
|                     props.openFullScreenToot(item, element); |                     props.openFullScreenToot(status, element); | ||||||
|                   } |                   } | ||||||
|                 }} |                 }} | ||||||
|               /> |               /> | ||||||
|  | @ -179,7 +165,7 @@ const TimelinePanel: Component<{ | ||||||
|           <Match when={snapshot.error}> |           <Match when={snapshot.error}> | ||||||
|             <Button |             <Button | ||||||
|               variant="contained" |               variant="contained" | ||||||
|               onClick={[refetchTimeline, "old"]} |               onClick={[refetchTimeline, "next"]} | ||||||
|               disabled={snapshot.loading} |               disabled={snapshot.loading} | ||||||
|             > |             > | ||||||
|               Retry |               Retry | ||||||
|  | @ -188,7 +174,7 @@ const TimelinePanel: Component<{ | ||||||
|           <Match when={typeof props.fullRefetch === "undefined"}> |           <Match when={typeof props.fullRefetch === "undefined"}> | ||||||
|             <Button |             <Button | ||||||
|               variant="contained" |               variant="contained" | ||||||
|               onClick={[refetchTimeline, "old"]} |               onClick={[refetchTimeline, "next"]} | ||||||
|               disabled={snapshot.loading} |               disabled={snapshot.loading} | ||||||
|             > |             > | ||||||
|               Load More |               Load More | ||||||
|  | @ -200,4 +186,4 @@ const TimelinePanel: Component<{ | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default TimelinePanel | export default TimelinePanel; | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue