cc98-cli 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -1
  3. package/dist/api/client.d.ts +1 -0
  4. package/dist/api/client.d.ts.map +1 -1
  5. package/dist/api/client.js +3 -0
  6. package/dist/api/client.js.map +1 -1
  7. package/dist/api/endpoints.d.ts +1 -0
  8. package/dist/api/endpoints.d.ts.map +1 -1
  9. package/dist/api/endpoints.js +1 -0
  10. package/dist/api/endpoints.js.map +1 -1
  11. package/dist/cli/commands/topic.d.ts.map +1 -1
  12. package/dist/cli/commands/topic.js +7 -0
  13. package/dist/cli/commands/topic.js.map +1 -1
  14. package/dist/cli/router.js +3 -0
  15. package/dist/cli/router.js.map +1 -1
  16. package/dist/tui/cached-client.d.ts +1 -0
  17. package/dist/tui/cached-client.d.ts.map +1 -1
  18. package/dist/tui/cached-client.js +3 -0
  19. package/dist/tui/cached-client.js.map +1 -1
  20. package/dist/tui/components/content.d.ts.map +1 -1
  21. package/dist/tui/components/content.js +5 -6
  22. package/dist/tui/components/content.js.map +1 -1
  23. package/dist/tui/components/sidebar.d.ts.map +1 -1
  24. package/dist/tui/components/sidebar.js +1 -11
  25. package/dist/tui/components/sidebar.js.map +1 -1
  26. package/dist/tui/components/status.d.ts.map +1 -1
  27. package/dist/tui/components/status.js +3 -0
  28. package/dist/tui/components/status.js.map +1 -1
  29. package/dist/tui/controller.d.ts +19 -0
  30. package/dist/tui/controller.d.ts.map +1 -1
  31. package/dist/tui/controller.js +415 -76
  32. package/dist/tui/controller.js.map +1 -1
  33. package/dist/tui/navigation.d.ts.map +1 -1
  34. package/dist/tui/navigation.js +2 -3
  35. package/dist/tui/navigation.js.map +1 -1
  36. package/dist/tui/renderer.js +11 -27
  37. package/dist/tui/renderer.js.map +1 -1
  38. package/dist/tui/state/store.d.ts.map +1 -1
  39. package/dist/tui/state/store.js +2 -1
  40. package/dist/tui/state/store.js.map +1 -1
  41. package/dist/tui/state/types.d.ts +27 -2
  42. package/dist/tui/state/types.d.ts.map +1 -1
  43. package/dist/tui/topic-reader.d.ts +2 -1
  44. package/dist/tui/topic-reader.d.ts.map +1 -1
  45. package/dist/tui/topic-reader.js +17 -5
  46. package/dist/tui/topic-reader.js.map +1 -1
  47. package/package.json +1 -1
@@ -8,7 +8,7 @@ import { EMOJI_CATEGORIES, getEmojiArt, renderCc98Logo, renderEmojiCode } from "
8
8
  import { navItems, settingsItems } from "./navigation.js";
9
9
  import { getStatus } from "./state/store.js";
10
10
  import { asArray, asNumber, asObject, chatItem, chatMessageItems, flattenBoards, genericItem, historyItem, isAbortError, jsonPreviewLines, loadChatUserNames, mapLimit, noticeItem, overviewStats, topicItem, unreadStats, userItem } from "./helpers.js";
11
- import { appendTopicPosts, buildTopicReader, currentTopicPost, findTopicPostByFloor, getTopicPageInfo, jumpToPage, FLOORS_PER_PAGE } from "./topic-reader.js";
11
+ import { appendTopicPosts, buildTopicReader, currentTopicLine, currentTopicPost, findTopicPostByFloor, getTopicPageInfo, jumpToPage, replaceTopicPosts, FLOORS_PER_PAGE } from "./topic-reader.js";
12
12
  export class TuiController {
13
13
  state;
14
14
  client;
@@ -23,6 +23,7 @@ export class TuiController {
23
23
  settingsStore = new SettingsStore();
24
24
  updateChecked = false;
25
25
  autoSigninChecked = false;
26
+ listReturnState;
26
27
  constructor(state, client, tokenStore, render, close, nextSignal, abortCurrent, webVpnOptions) {
27
28
  this.state = state;
28
29
  this.client = client;
@@ -54,6 +55,7 @@ export class TuiController {
54
55
  this.state.parentList = undefined;
55
56
  this.state.currentBoard = undefined;
56
57
  this.state.currentChat = undefined;
58
+ this.state.listPaging = undefined;
57
59
  this.render();
58
60
  try {
59
61
  // 加载快捷键配置
@@ -74,6 +76,7 @@ export class TuiController {
74
76
  this.state.viewTitle = next.title;
75
77
  this.state.items = next.items;
76
78
  this.state.stats = next.stats;
79
+ this.state.listPaging = next.paging;
77
80
  if (next.overview) {
78
81
  this.state.overview = next.overview;
79
82
  }
@@ -90,6 +93,9 @@ export class TuiController {
90
93
  if (version === this.loadVersion) {
91
94
  this.state.loading = false;
92
95
  this.render();
96
+ if (this.state.listPaging?.hasMore) {
97
+ void this.ensureListWindowFilled(this.nextSignal());
98
+ }
93
99
  if (shouldAutoSignin) {
94
100
  void this.runAutoSignin();
95
101
  }
@@ -189,9 +195,7 @@ export class TuiController {
189
195
  }
190
196
  handleSearchKey(key) {
191
197
  if (this.keybindings.matches(key, "searchClose")) {
192
- this.state.modal = null;
193
- this.state.searchQuery = "";
194
- this.render();
198
+ this.closeSearch();
195
199
  return;
196
200
  }
197
201
  if (this.keybindings.matches(key, "searchToggleMode")) {
@@ -215,7 +219,7 @@ export class TuiController {
215
219
  const selected = this.state.searchResults[this.state.itemIndex];
216
220
  if (selected) {
217
221
  // 有选中项:打开
218
- this.state.modal = null;
222
+ this.restoreSearchOriginForActivation();
219
223
  void this.activateContentItem(selected, this.nextSignal());
220
224
  }
221
225
  else if (this.state.searchQuery.trim()) {
@@ -402,7 +406,7 @@ export class TuiController {
402
406
  }
403
407
  // r:刷新
404
408
  if (this.keybindings.matches(key, "topicRefresh") && this.state.topic) {
405
- void this.openTopic(this.state.topic.topicId, true, this.nextSignal());
409
+ void this.refreshCurrentTopic(this.nextSignal());
406
410
  return;
407
411
  }
408
412
  // s:收藏
@@ -493,9 +497,20 @@ export class TuiController {
493
497
  void this.load(true);
494
498
  }
495
499
  handleContentKey(key) {
500
+ if ((key === "\t" || key === "\x1b[Z") && this.state.tabs.length > 1) {
501
+ this.switchTab(key === "\x1b[Z" ? -1 : 1);
502
+ return;
503
+ }
504
+ if (/^[1-9]$/.test(key) && this.state.tabs.length > 1) {
505
+ this.switchTabToIndex(Number(key) - 1);
506
+ return;
507
+ }
496
508
  if (this.keybindings.matches(key, "listNext")) {
509
+ const previousIndex = this.state.itemIndex;
510
+ const shouldAdvanceAfterLoad = previousIndex >= this.state.items.length - 1;
497
511
  this.state.itemIndex = Math.min(Math.max(0, this.state.items.length - 1), this.state.itemIndex + 1);
498
512
  this.render();
513
+ void this.checkListAutoLoad(shouldAdvanceAfterLoad ? previousIndex + 1 : undefined);
499
514
  return;
500
515
  }
501
516
  if (this.keybindings.matches(key, "listPrev")) {
@@ -525,13 +540,22 @@ export class TuiController {
525
540
  if (this.keybindings.matches(key, "menu"))
526
541
  this.openMenu();
527
542
  }
543
+ switchTab(delta) {
544
+ const current = Math.max(0, this.state.tabs.findIndex((tab) => tab.id === this.state.tabId));
545
+ const next = (current + delta + this.state.tabs.length) % this.state.tabs.length;
546
+ this.switchTabToIndex(next);
547
+ }
548
+ switchTabToIndex(index) {
549
+ const tab = this.state.tabs[index];
550
+ if (!tab || tab.id === this.state.tabId)
551
+ return;
552
+ this.state.tabId = tab.id;
553
+ void this.load(true);
554
+ }
528
555
  leave() {
529
556
  this.abortCurrent();
530
557
  if (this.state.mode === "topic") {
531
- this.state.mode = "list";
532
- this.state.focus = "content";
533
- this.state.status = "";
534
- this.render();
558
+ void this.leaveTopic();
535
559
  return;
536
560
  }
537
561
  if (this.state.parentList) {
@@ -543,8 +567,139 @@ export class TuiController {
543
567
  this.state.status = "";
544
568
  this.render();
545
569
  }
570
+ rememberListReturnState() {
571
+ if (this.state.mode !== "list")
572
+ return;
573
+ this.listReturnState = {
574
+ itemIndex: this.state.itemIndex,
575
+ scroll: this.state.scroll,
576
+ paging: this.cloneListPaging(this.state.listPaging)
577
+ };
578
+ }
579
+ async leaveTopic() {
580
+ const listReturn = this.listReturnState;
581
+ this.state.mode = "list";
582
+ this.state.focus = "content";
583
+ this.state.status = "";
584
+ this.state.topic = undefined;
585
+ if (listReturn) {
586
+ this.state.itemIndex = Math.min(Math.max(0, this.state.items.length - 1), listReturn.itemIndex);
587
+ this.state.listPaging = this.cloneListPaging(listReturn.paging);
588
+ this.state.scroll = listReturn.paging?.anchorOnReturn ? this.state.itemIndex : listReturn.scroll;
589
+ this.listReturnState = undefined;
590
+ }
591
+ this.render();
592
+ if (this.state.listPaging?.anchorOnReturn) {
593
+ await this.ensureListWindowFilled(this.nextSignal());
594
+ }
595
+ }
596
+ async ensureListWindowFilled(signal) {
597
+ const paging = this.state.listPaging;
598
+ if (!paging?.hasMore || this.state.loadingMore)
599
+ return;
600
+ const capacity = Math.max(1, this.state.listViewportCapacity || 10);
601
+ const targetLength = this.state.scroll + capacity;
602
+ while (this.state.items.length < targetLength && paging.hasMore && !this.state.loadingMore) {
603
+ const previousLength = this.state.items.length;
604
+ await this.loadNextListPage(signal);
605
+ if (this.state.items.length === previousLength)
606
+ break;
607
+ }
608
+ }
609
+ async checkListAutoLoad(advanceToIndex) {
610
+ const paging = this.state.listPaging;
611
+ if (!paging?.hasMore || this.state.loadingMore)
612
+ return;
613
+ const capacity = Math.max(1, this.state.listViewportCapacity || 10);
614
+ const visibleEnd = this.state.scroll + capacity;
615
+ if (this.state.items.length <= visibleEnd || this.state.itemIndex >= this.state.items.length - 2) {
616
+ await this.loadNextListPage(this.nextSignal());
617
+ if (advanceToIndex !== undefined && advanceToIndex < this.state.items.length) {
618
+ this.state.itemIndex = advanceToIndex;
619
+ this.state.status = "";
620
+ this.render();
621
+ }
622
+ }
623
+ }
624
+ async loadNextListPage(signal) {
625
+ const paging = this.state.listPaging;
626
+ if (!paging?.hasMore || this.state.loadingMore)
627
+ return;
628
+ this.state.loadingMore = true;
629
+ this.render();
630
+ try {
631
+ const nextItems = await this.fetchNextListPage(paging, signal);
632
+ this.state.items.push(...nextItems);
633
+ paging.loaded += nextItems.length;
634
+ if (paging.kind !== "favorite-board-topics") {
635
+ paging.hasMore = nextItems.length >= paging.size;
636
+ }
637
+ this.state.stats = this.state.stats.map((item) => {
638
+ if (item.title === "版面主题" && paging.kind === "favorite-board-topics") {
639
+ return { ...item, detail: `${paging.loaded} 条` };
640
+ }
641
+ if (["主题", "新帖流", "最新新帖", "关注用户", "收藏更新"].includes(item.title)) {
642
+ return { ...item, detail: `${this.state.items.length} 条` };
643
+ }
644
+ return item;
645
+ });
646
+ this.state.status = paging.hasMore ? "已加载更多" : "已到底";
647
+ }
648
+ catch (error) {
649
+ if (!isAbortError(error))
650
+ this.state.status = error instanceof Error ? error.message : "加载失败";
651
+ }
652
+ finally {
653
+ this.state.loadingMore = false;
654
+ this.render();
655
+ }
656
+ }
657
+ async fetchNextListPage(paging, signal) {
658
+ if (paging.kind === "new-topics") {
659
+ const topics = asArray(await this.client.getNewTopics(paging.loaded, paging.size, false, signal));
660
+ return topics.map((topic) => topicItem(topic));
661
+ }
662
+ if (paging.kind === "followee-topics") {
663
+ const topics = asArray(await this.client.getFolloweeTopics(paging.loaded, paging.size, false, signal));
664
+ return topics.map((topic) => topicItem(topic));
665
+ }
666
+ if (paging.kind === "favorite-board-topics") {
667
+ return this.fetchNextFavoriteBoardTopics(paging, signal);
668
+ }
669
+ const order = paging.kind === "favorite-updates" ? 1 : 0;
670
+ const topics = asArray(await this.client.getFavoriteTopics(paging.loaded, paging.size, order, 0, false, signal));
671
+ return topics.map((topic) => topicItem(topic));
672
+ }
673
+ async fetchNextFavoriteBoardTopics(paging, signal) {
674
+ const take = Math.max(1, Math.min(20, paging.size));
675
+ if ((paging.buffer?.length ?? 0) >= take) {
676
+ const items = paging.buffer?.splice(0, take) ?? [];
677
+ paging.hasMore = (paging.buffer?.length ?? 0) > 0 || (paging.boardCursors?.some((cursor) => cursor.hasMore) ?? false);
678
+ return items;
679
+ }
680
+ const cursors = paging.boardCursors?.filter((cursor) => cursor.hasMore) ?? [];
681
+ if (cursors.length === 0) {
682
+ const items = paging.buffer?.splice(0, take) ?? [];
683
+ paging.hasMore = (paging.buffer?.length ?? 0) > 0;
684
+ return items;
685
+ }
686
+ const batches = await mapLimit(cursors, 3, async (cursor) => {
687
+ const topics = asArray(await this.client.getBoardTopics(cursor.boardId, cursor.loaded, cursor.size, false, false, signal));
688
+ cursor.loaded += topics.length;
689
+ cursor.hasMore = topics.length >= cursor.size;
690
+ return topics.map((topic) => topicItem(topic, { title: cursor.title, boardId: cursor.boardId }));
691
+ });
692
+ const merged = [...(paging.buffer ?? []), ...batches.flat()]
693
+ .sort((left, right) => (right.sortTime ?? 0) - (left.sortTime ?? 0));
694
+ paging.buffer = merged;
695
+ paging.hasMore = paging.buffer.length > 0 || cursors.some((cursor) => cursor.hasMore);
696
+ return paging.buffer.splice(0, take);
697
+ }
546
698
  refresh() {
547
- if (this.state.currentBoard) {
699
+ if (this.state.mode === "topic" && this.state.topic) {
700
+ void this.refreshCurrentTopic(this.nextSignal());
701
+ }
702
+ else if (this.state.currentBoard) {
548
703
  void this.openBoard(this.state.currentBoard.boardId, this.state.currentBoard.title, true, this.nextSignal(), false);
549
704
  }
550
705
  else if (this.state.currentChat) {
@@ -556,6 +711,7 @@ export class TuiController {
556
711
  }
557
712
  openSearch() {
558
713
  this.state.modal = "search";
714
+ this.state.searchOrigin = { itemIndex: this.state.itemIndex, scroll: this.state.scroll };
559
715
  this.state.searchQuery = "";
560
716
  this.state.searchResults = [];
561
717
  this.state.searchMode = "topics";
@@ -563,6 +719,29 @@ export class TuiController {
563
719
  this.state.itemIndex = 0;
564
720
  this.render();
565
721
  }
722
+ closeSearch() {
723
+ const origin = this.state.searchOrigin;
724
+ this.state.modal = null;
725
+ this.state.searchOrigin = undefined;
726
+ this.state.searchQuery = "";
727
+ this.state.searchResults = [];
728
+ if (origin) {
729
+ this.state.itemIndex = origin.itemIndex;
730
+ this.state.scroll = origin.scroll;
731
+ }
732
+ this.render();
733
+ }
734
+ restoreSearchOriginForActivation() {
735
+ const origin = this.state.searchOrigin;
736
+ this.state.modal = null;
737
+ this.state.searchOrigin = undefined;
738
+ this.state.searchQuery = "";
739
+ this.state.searchResults = [];
740
+ if (origin) {
741
+ this.state.itemIndex = origin.itemIndex;
742
+ this.state.scroll = origin.scroll;
743
+ }
744
+ }
566
745
  getSearchScope() {
567
746
  // 根据当前位置确定搜索范围
568
747
  if (this.state.currentBoard) {
@@ -598,9 +777,19 @@ export class TuiController {
598
777
  this.state.userDetail = undefined;
599
778
  this.render();
600
779
  }
780
+ setTabs(tabs, defaultId) {
781
+ this.state.tabs = tabs;
782
+ if (!tabs.some((tab) => tab.id === this.state.tabId)) {
783
+ this.state.tabId = defaultId;
784
+ }
785
+ }
601
786
  async loadView(view, force, signal) {
787
+ if (view !== "new" && view !== "following") {
788
+ this.setTabs([{ id: "default", label: "" }], "default");
789
+ }
602
790
  switch (view) {
603
791
  case "hot": {
792
+ this.setTabs([{ id: "default", label: "" }], "default");
604
793
  const [index, unread] = await Promise.all([
605
794
  this.client.getForumIndex(force, signal),
606
795
  this.client.getUnreadCount(force, signal)
@@ -612,14 +801,46 @@ export class TuiController {
612
801
  title: "十大",
613
802
  items: hotTopics.map((topic) => topicItem(topic)),
614
803
  stats: unreadStats(unreadObject),
615
- overview: overviewStats(indexObject, unreadObject)
804
+ overview: overviewStats(indexObject, unreadObject),
805
+ status: "十大:j/k 选择 Enter 打开 r 刷新"
616
806
  };
617
807
  }
618
808
  case "new": {
619
- const topics = asArray(await this.client.getNewTopics(0, 12, force, signal));
620
- return { title: "最新", items: topics.map((topic) => topicItem(topic)), stats: [{ title: "新帖流", detail: `${topics.length} 条` }] };
809
+ this.setTabs([
810
+ { id: "new-latest", label: "最新" },
811
+ { id: "new-random", label: "随机" },
812
+ { id: "new-recommendation", label: "推荐" }
813
+ ], "new-latest");
814
+ if (this.state.tabId === "new-recommendation") {
815
+ const topics = asArray(await this.client.getRandomRecommendations(10, true, signal));
816
+ return {
817
+ title: "新帖 · 推荐",
818
+ items: topics.map((topic) => topicItem(topic)),
819
+ stats: [{ title: "推荐", detail: `${topics.length} 条` }],
820
+ status: "新帖:Tab 切换 j/k 选择 Enter 打开 r 换一批"
821
+ };
822
+ }
823
+ if (this.state.tabId === "new-random") {
824
+ const topics = asArray(await this.client.getRandomTopics(20, true, signal));
825
+ return {
826
+ title: "新帖 · 随机",
827
+ items: topics.map((topic) => topicItem(topic)),
828
+ stats: [{ title: "随机新帖", detail: `${topics.length} 条` }],
829
+ status: "新帖:Tab 切换 j/k 选择 Enter 打开 r 换一批"
830
+ };
831
+ }
832
+ const size = 20;
833
+ const topics = asArray(await this.client.getNewTopics(0, size, force, signal));
834
+ return {
835
+ title: "新帖 · 最新",
836
+ items: topics.map((topic) => topicItem(topic)),
837
+ stats: [{ title: "最新新帖", detail: `${topics.length} 条` }],
838
+ status: "新帖:Tab 切换 j/k 选择 Enter 打开 r 刷新",
839
+ paging: { kind: "new-topics", loaded: topics.length, size, hasMore: topics.length >= size, anchorOnReturn: true }
840
+ };
621
841
  }
622
842
  case "boards": {
843
+ this.setTabs([{ id: "default", label: "" }], "default");
623
844
  const sections = asArray(await this.client.getAllBoards(force, signal));
624
845
  const boards = flattenBoards(sections);
625
846
  return {
@@ -630,15 +851,37 @@ export class TuiController {
630
851
  };
631
852
  }
632
853
  case "following": {
633
- const topics = asArray(await this.client.getFolloweeTopics(0, 12, force, signal));
634
- return {
635
- title: "关注",
636
- items: topics.map((topic) => topicItem(topic)),
637
- stats: [{ title: "关注动态", detail: `${topics.length} 条` }, { title: "缓存", detail: "30s" }],
638
- status: "关注:j/k 选择 Enter 打开帖子 h 返回 r 刷新"
639
- };
854
+ this.setTabs([
855
+ { id: "follow-boards", label: "版面" },
856
+ { id: "follow-users", label: "用户" },
857
+ { id: "follow-favorites", label: "追踪" }
858
+ ], "follow-boards");
859
+ if (this.state.tabId === "follow-users") {
860
+ const size = 20;
861
+ const topics = asArray(await this.client.getFolloweeTopics(0, size, force, signal));
862
+ return {
863
+ title: "关注 · 用户",
864
+ items: topics.map((topic) => topicItem(topic)),
865
+ stats: [{ title: "关注用户", detail: `${topics.length} 条` }],
866
+ status: "关注:Tab 切换 j/k 选择 Enter 打开 r 刷新",
867
+ paging: { kind: "followee-topics", loaded: topics.length, size, hasMore: topics.length >= size, anchorOnReturn: true }
868
+ };
869
+ }
870
+ if (this.state.tabId === "follow-favorites") {
871
+ const size = 20;
872
+ const topics = asArray(await this.client.getFavoriteTopics(0, size, 1, 0, force, signal));
873
+ return {
874
+ title: "关注 · 追踪",
875
+ items: topics.map((topic) => topicItem(topic)),
876
+ stats: [{ title: "收藏更新", detail: `${topics.length} 条` }],
877
+ status: "关注:Tab 切换 j/k 选择 Enter 打开 r 刷新",
878
+ paging: { kind: "favorite-updates", loaded: topics.length, size, hasMore: topics.length >= size, anchorOnReturn: true }
879
+ };
880
+ }
881
+ return this.loadFavoriteBoardTopics(force, signal);
640
882
  }
641
883
  case "favorite": {
884
+ this.setTabs([{ id: "default", label: "" }], "default");
642
885
  const [meRaw, sectionsRaw, topicFavorites] = await Promise.all([
643
886
  this.client.getMe(force, signal),
644
887
  this.client.getAllBoards(false, signal),
@@ -647,12 +890,20 @@ export class TuiController {
647
890
  const customBoards = asArray(asObject(meRaw).customBoards).filter((id) => typeof id === "number");
648
891
  const allBoards = flattenBoards(asArray(sectionsRaw));
649
892
  const boardById = new Map(allBoards.filter((board) => board.boardId !== undefined).map((board) => [board.boardId, board]));
893
+ const boardPageSize = 5;
650
894
  const topicGroups = await mapLimit(customBoards, 3, async (boardId) => {
651
895
  const board = boardById.get(boardId);
652
- const topics = asArray(await this.client.getBoardTopics(boardId, 0, 3, false, force, signal));
653
- return topics.map((topic) => topicItem(topic, board));
896
+ const topics = asArray(await this.client.getBoardTopics(boardId, 0, boardPageSize, false, force, signal));
897
+ return { boardId, board, topics: topics.map((topic) => topicItem(topic, board)), hasMore: topics.length >= boardPageSize };
654
898
  });
655
- const boardTopics = topicGroups.flat().sort((left, right) => (right.sortTime ?? 0) - (left.sortTime ?? 0)).slice(0, 12);
899
+ const boardTopics = topicGroups.flatMap((group) => group.topics).sort((left, right) => (right.sortTime ?? 0) - (left.sortTime ?? 0));
900
+ const boardCursors = topicGroups.map((group) => ({
901
+ boardId: group.boardId,
902
+ title: group.board?.title ?? `#${group.boardId}`,
903
+ loaded: group.topics.length,
904
+ size: boardPageSize,
905
+ hasMore: group.hasMore
906
+ }));
656
907
  return {
657
908
  title: "收藏",
658
909
  items: [
@@ -667,7 +918,15 @@ export class TuiController {
667
918
  { title: "收藏主题", detail: `${asArray(topicFavorites).length} 条` },
668
919
  { title: "版面主题", detail: `${boardTopics.length} 条` }
669
920
  ],
670
- status: "收藏:j/k 选择 Enter 打开 h 返回 r 刷新"
921
+ status: "收藏:j/k 选择 Enter 打开 h 返回 r 刷新",
922
+ paging: {
923
+ kind: "favorite-board-topics",
924
+ loaded: boardTopics.length,
925
+ size: Math.max(12, customBoards.length * boardPageSize),
926
+ hasMore: boardCursors.some((cursor) => cursor.hasMore),
927
+ anchorOnReturn: true,
928
+ boardCursors
929
+ }
671
930
  };
672
931
  }
673
932
  case "messages": {
@@ -736,6 +995,46 @@ export class TuiController {
736
995
  }
737
996
  }
738
997
  }
998
+ async loadFavoriteBoardTopics(force, signal) {
999
+ const [meRaw, sectionsRaw] = await Promise.all([
1000
+ this.client.getMe(force, signal),
1001
+ this.client.getAllBoards(false, signal)
1002
+ ]);
1003
+ const customBoards = asArray(asObject(meRaw).customBoards).filter((id) => typeof id === "number");
1004
+ const allBoards = flattenBoards(asArray(sectionsRaw));
1005
+ const boardById = new Map(allBoards.filter((board) => board.boardId !== undefined).map((board) => [board.boardId, board]));
1006
+ const boardPageSize = 5;
1007
+ const topicGroups = await mapLimit(customBoards, 3, async (boardId) => {
1008
+ const board = boardById.get(boardId);
1009
+ const topics = asArray(await this.client.getBoardTopics(boardId, 0, boardPageSize, false, force, signal));
1010
+ return { boardId, board, topics: topics.map((topic) => topicItem(topic, board)), hasMore: topics.length >= boardPageSize };
1011
+ });
1012
+ const boardTopics = topicGroups.flatMap((group) => group.topics).sort((left, right) => (right.sortTime ?? 0) - (left.sortTime ?? 0));
1013
+ const boardCursors = topicGroups.map((group) => ({
1014
+ boardId: group.boardId,
1015
+ title: group.board?.title ?? `#${group.boardId}`,
1016
+ loaded: group.topics.length,
1017
+ size: boardPageSize,
1018
+ hasMore: group.hasMore
1019
+ }));
1020
+ return {
1021
+ title: "关注 · 版面",
1022
+ items: boardTopics,
1023
+ stats: [
1024
+ { title: "关注版面", detail: `${customBoards.length} 个` },
1025
+ { title: "版面主题", detail: `${boardTopics.length} 条` }
1026
+ ],
1027
+ status: "关注:Tab 切换 j/k 选择 Enter 打开 r 刷新",
1028
+ paging: {
1029
+ kind: "favorite-board-topics",
1030
+ loaded: boardTopics.length,
1031
+ size: Math.max(12, customBoards.length * boardPageSize),
1032
+ hasMore: boardCursors.some((cursor) => cursor.hasMore),
1033
+ anchorOnReturn: true,
1034
+ boardCursors
1035
+ }
1036
+ };
1037
+ }
739
1038
  async activateSetting(selected) {
740
1039
  if (!selected)
741
1040
  return;
@@ -839,6 +1138,7 @@ export class TuiController {
839
1138
  }
840
1139
  async activateContentItem(selected, signal) {
841
1140
  if (selected.topicId !== undefined) {
1141
+ this.rememberListReturnState();
842
1142
  await this.openTopic(selected.topicId, false, signal);
843
1143
  return;
844
1144
  }
@@ -864,11 +1164,6 @@ export class TuiController {
864
1164
  this.openEmojiCategory(selected.meta.slice("emoji-category:".length));
865
1165
  return;
866
1166
  }
867
- if (selected.meta?.startsWith("emoji-batch:")) {
868
- this.state.status = "继续向下选择具体表情,Enter 放大预览";
869
- this.render();
870
- return;
871
- }
872
1167
  if (selected.meta?.startsWith("emoji:")) {
873
1168
  this.openEmojiDetail(selected.meta.slice("emoji:".length));
874
1169
  return;
@@ -884,7 +1179,7 @@ export class TuiController {
884
1179
  this.state.status = "当前条目不可进入";
885
1180
  this.render();
886
1181
  }
887
- async openTopic(topicId, force, signal) {
1182
+ async openTopic(topicId, force, signal, restore) {
888
1183
  this.state.mode = "topic";
889
1184
  this.state.loading = true;
890
1185
  this.state.error = undefined;
@@ -893,11 +1188,15 @@ export class TuiController {
893
1188
  this.state.scroll = 0;
894
1189
  this.render();
895
1190
  try {
1191
+ const from = restore ? Math.max(0, restore.floor - 1) : 0;
1192
+ const size = 10;
896
1193
  const [topicRaw, postsRaw] = await Promise.all([
897
1194
  this.client.getTopic(topicId, force, signal),
898
- this.client.getTopicPosts(topicId, 0, 10, force, signal)
1195
+ this.client.getTopicPosts(topicId, from, size, force, signal)
899
1196
  ]);
900
- this.state.topic = buildTopicReader(topicId, asObject(topicRaw), asArray(postsRaw), 10);
1197
+ this.state.topic = buildTopicReader(topicId, asObject(topicRaw), asArray(postsRaw), size, from);
1198
+ if (restore)
1199
+ this.restoreTopicPosition(restore);
901
1200
  this.state.loading = false;
902
1201
  this.state.status = "";
903
1202
  }
@@ -913,6 +1212,31 @@ export class TuiController {
913
1212
  void this.preloadTopicImages(this.state.topic);
914
1213
  }
915
1214
  }
1215
+ async refreshCurrentTopic(signal) {
1216
+ const topic = this.state.topic;
1217
+ if (!topic)
1218
+ return;
1219
+ const restore = this.getTopicRestoreTarget(topic);
1220
+ await this.openTopic(topic.topicId, true, signal, restore);
1221
+ }
1222
+ getTopicRestoreTarget(topic) {
1223
+ const post = currentTopicPost(topic, topic.cursorLine);
1224
+ return {
1225
+ floor: post?.floor ?? 1,
1226
+ lineOffset: post ? Math.max(0, topic.cursorLine - post.lineStart) : 0,
1227
+ loaded: topic.loaded
1228
+ };
1229
+ }
1230
+ restoreTopicPosition(target) {
1231
+ const topic = this.state.topic;
1232
+ if (!topic)
1233
+ return;
1234
+ const post = findTopicPostByFloor(topic, target.floor);
1235
+ if (!post)
1236
+ return;
1237
+ topic.cursorLine = Math.min(post.lineEnd, post.lineStart + target.lineOffset);
1238
+ this.state.scroll = topic.cursorLine;
1239
+ }
916
1240
  async openBoard(boardId, title, force, signal, pushParent = true) {
917
1241
  if (pushParent)
918
1242
  this.snapshotParent();
@@ -921,6 +1245,7 @@ export class TuiController {
921
1245
  this.state.viewTitle = title;
922
1246
  this.state.focus = "content";
923
1247
  this.state.currentBoard = { boardId, title };
1248
+ this.state.listPaging = undefined;
924
1249
  this.state.itemIndex = 0;
925
1250
  this.state.scroll = 0;
926
1251
  this.render();
@@ -946,6 +1271,7 @@ export class TuiController {
946
1271
  this.state.error = undefined;
947
1272
  this.state.viewTitle = `私信: ${title}`;
948
1273
  this.state.focus = "content";
1274
+ this.state.listPaging = undefined;
949
1275
  this.state.itemIndex = 0;
950
1276
  this.state.scroll = 0;
951
1277
  this.render();
@@ -1042,9 +1368,7 @@ export class TuiController {
1042
1368
  return;
1043
1369
  const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
1044
1370
  if (pageInfo.currentPage > 1) {
1045
- topic.cursorLine = jumpToPage(topic, pageInfo.currentPage - 1);
1046
- this.state.status = "";
1047
- this.render();
1371
+ await this.jumpToTopicPage(pageInfo.currentPage - 1, this.nextSignal());
1048
1372
  }
1049
1373
  }
1050
1374
  async jumpToTopicPage(page, signal) {
@@ -1057,13 +1381,9 @@ export class TuiController {
1057
1381
  this.render();
1058
1382
  return;
1059
1383
  }
1060
- // 如果目标页未加载,需要加载到该页
1061
1384
  const targetFloor = (page - 1) * FLOORS_PER_PAGE + 1;
1062
- while (topic.hasMore && !findTopicPostByFloor(topic, targetFloor)) {
1063
- const previousLoaded = topic.loaded;
1064
- await this.loadNextTopicPage(signal, true);
1065
- if (topic.loaded === previousLoaded)
1066
- break;
1385
+ if (!findTopicPostByFloor(topic, targetFloor)) {
1386
+ await this.loadTopicWindow(targetFloor, signal);
1067
1387
  }
1068
1388
  const post = findTopicPostByFloor(topic, targetFloor);
1069
1389
  if (post) {
@@ -1075,6 +1395,30 @@ export class TuiController {
1075
1395
  }
1076
1396
  this.render();
1077
1397
  }
1398
+ async loadTopicWindow(startFloor, signal) {
1399
+ const topic = this.state.topic;
1400
+ if (!topic || this.state.loadingMore)
1401
+ return;
1402
+ const from = Math.max(0, startFloor - 1);
1403
+ this.state.loadingMore = true;
1404
+ this.render();
1405
+ try {
1406
+ const posts = asArray(await this.client.getTopicPosts(topic.topicId, from, topic.size, false, signal));
1407
+ replaceTopicPosts(topic, posts, from);
1408
+ topic.hasMore = from + posts.length < topic.totalFloors;
1409
+ this.state.scroll = 0;
1410
+ this.state.status = "";
1411
+ void this.preloadTopicImages(topic);
1412
+ }
1413
+ catch (error) {
1414
+ if (!isAbortError(error))
1415
+ this.state.status = error instanceof Error ? error.message : "加载失败";
1416
+ }
1417
+ finally {
1418
+ this.state.loadingMore = false;
1419
+ this.render();
1420
+ }
1421
+ }
1078
1422
  async loadNextTopicPage(signal, quiet = false) {
1079
1423
  const topic = this.state.topic;
1080
1424
  if (!topic?.hasMore || this.state.loadingMore)
@@ -1108,11 +1452,8 @@ export class TuiController {
1108
1452
  this.render();
1109
1453
  return;
1110
1454
  }
1111
- while (topic.hasMore && !findTopicPostByFloor(topic, floor)) {
1112
- const previousLoaded = topic.loaded;
1113
- await this.loadNextTopicPage(signal, true);
1114
- if (topic.loaded === previousLoaded)
1115
- break;
1455
+ if (!findTopicPostByFloor(topic, floor)) {
1456
+ await this.loadTopicWindow(floor, signal);
1116
1457
  }
1117
1458
  const post = findTopicPostByFloor(topic, floor);
1118
1459
  topic.cursorLine = post?.lineStart ?? topic.cursorLine;
@@ -1139,8 +1480,9 @@ export class TuiController {
1139
1480
  case "favorite-topics":
1140
1481
  case "favorite-updates": {
1141
1482
  const order = action === "favorite-updates" ? 1 : 0;
1142
- const topics = asArray(await this.client.getFavoriteTopics(0, 11, order, 0, false, signal));
1143
- this.openReadOnlyList(action === "favorite-updates" ? "收藏更新" : "收藏主题", topics.map((topic) => topicItem(topic)), [{ title: "主题", detail: `${topics.length}` }]);
1483
+ const size = 20;
1484
+ const topics = asArray(await this.client.getFavoriteTopics(0, size, order, 0, false, signal));
1485
+ this.openReadOnlyList(action === "favorite-updates" ? "收藏更新" : "收藏主题", topics.map((topic) => topicItem(topic)), [{ title: "主题", detail: `${topics.length}` }], { kind: action === "favorite-updates" ? "favorite-updates" : "favorite-topics", loaded: topics.length, size, hasMore: topics.length >= size, anchorOnReturn: true });
1144
1486
  return;
1145
1487
  }
1146
1488
  case "favorite-groups": {
@@ -1359,12 +1701,7 @@ export class TuiController {
1359
1701
  const topic = this.state.topic;
1360
1702
  if (!topic)
1361
1703
  return undefined;
1362
- for (const post of topic.posts) {
1363
- const line = post.lines.find((entry) => entry.line === topic.cursorLine);
1364
- if (line)
1365
- return line;
1366
- }
1367
- return undefined;
1704
+ return currentTopicLine(topic, topic.cursorLine);
1368
1705
  }
1369
1706
  /**
1370
1707
  * Preload images for topic in background
@@ -1589,24 +1926,14 @@ export class TuiController {
1589
1926
  this.render();
1590
1927
  return;
1591
1928
  }
1592
- const items = [];
1593
- for (let index = 0; index < category.codes.length; index += 20) {
1594
- const batch = category.codes.slice(index, index + 20);
1595
- const batchNumber = Math.floor(index / 20) + 1;
1596
- items.push({
1597
- title: `${category.label} ${batchNumber} 批`,
1598
- meta: `emoji-batch:${category.id}:${batchNumber}`,
1599
- detail: `${batch[0]} - ${batch.at(-1)} · ${batch.length} 个`
1600
- });
1601
- for (const code of batch) {
1602
- const art = getEmojiArt(code);
1603
- items.push({
1604
- title: `[${code}]`,
1605
- meta: `emoji:${code}`,
1606
- detail: art ? `${category.label} · ${art.width}x${art.height}px` : category.label
1607
- });
1608
- }
1609
- }
1929
+ const items = category.codes.map((code) => {
1930
+ const art = getEmojiArt(code);
1931
+ return {
1932
+ title: `[${code}]`,
1933
+ meta: `emoji:${code}`,
1934
+ detail: art ? `${category.label} · ${art.width}x${art.height}px` : category.label
1935
+ };
1936
+ });
1610
1937
  this.openReadOnlyList(category.label, items, [
1611
1938
  { title: "分类", detail: category.label },
1612
1939
  { title: "数量", detail: `${category.codes.length} 个` },
@@ -1635,7 +1962,7 @@ export class TuiController {
1635
1962
  }
1636
1963
  async openAccountSwitcher() {
1637
1964
  try {
1638
- const accounts = await this.tokenStore.listAccounts();
1965
+ const accounts = (await this.tokenStore.listAccounts()).filter((account) => account.account !== "default");
1639
1966
  const currentAccount = await this.tokenStore.getCurrentAccountName();
1640
1967
  if (accounts.length === 0) {
1641
1968
  this.state.modal = "info";
@@ -1654,7 +1981,7 @@ export class TuiController {
1654
1981
  this.state.viewTitle = "切换账号";
1655
1982
  this.state.items = items;
1656
1983
  this.state.stats = [{ title: "账号数", detail: `${accounts.length}` }];
1657
- this.state.itemIndex = accounts.findIndex(a => a.account === currentAccount);
1984
+ this.state.itemIndex = Math.max(0, accounts.findIndex(a => a.account === currentAccount));
1658
1985
  this.state.scroll = 0;
1659
1986
  this.state.focus = "content";
1660
1987
  this.state.mode = "list";
@@ -1752,11 +2079,12 @@ export class TuiController {
1752
2079
  const users = asArray(await this.client.getUsers(ids, false, signal));
1753
2080
  this.openReadOnlyList(type === "follower" ? "粉丝列表" : "关注列表", users.map((user) => userItem(user)), [{ title: "用户", detail: `${users.length}` }]);
1754
2081
  }
1755
- openReadOnlyList(title, items, stats) {
2082
+ openReadOnlyList(title, items, stats, paging) {
1756
2083
  this.snapshotParent();
1757
2084
  this.state.viewTitle = title;
1758
2085
  this.state.items = items;
1759
2086
  this.state.stats = stats;
2087
+ this.state.listPaging = paging;
1760
2088
  this.state.itemIndex = 0;
1761
2089
  this.state.scroll = 0;
1762
2090
  this.state.focus = "content";
@@ -1775,6 +2103,7 @@ export class TuiController {
1775
2103
  itemIndex: this.state.itemIndex,
1776
2104
  scroll: this.state.scroll,
1777
2105
  status: this.state.status,
2106
+ paging: this.cloneListPaging(this.state.listPaging),
1778
2107
  parent: this.state.parentList
1779
2108
  };
1780
2109
  }
@@ -1788,6 +2117,7 @@ export class TuiController {
1788
2117
  this.state.itemIndex = parent.itemIndex;
1789
2118
  this.state.scroll = parent.scroll;
1790
2119
  this.state.status = parent.status;
2120
+ this.state.listPaging = this.cloneListPaging(parent.paging);
1791
2121
  this.state.parentList = parent.parent;
1792
2122
  this.state.currentBoard = undefined;
1793
2123
  this.state.currentChat = undefined;
@@ -1796,6 +2126,15 @@ export class TuiController {
1796
2126
  this.state.focus = "content";
1797
2127
  this.render();
1798
2128
  }
2129
+ cloneListPaging(paging) {
2130
+ if (!paging)
2131
+ return undefined;
2132
+ return {
2133
+ ...paging,
2134
+ boardCursors: paging.boardCursors?.map((cursor) => ({ ...cursor })),
2135
+ buffer: paging.buffer?.map((itemValue) => ({ ...itemValue }))
2136
+ };
2137
+ }
1799
2138
  async checkUpdate(forceShow = false) {
1800
2139
  if (forceShow) {
1801
2140
  this.state.status = "正在检查 GitHub Release...";