首页
/ Folo 移动端 v0.2.0 深度解读:Timeline 私密订阅过滤、上拉连续阅读与 folo/follow 双协议深度链接

Folo 移动端 v0.2.0 深度解读:Timeline 私密订阅过滤、上拉连续阅读与 folo/follow 双协议深度链接

2026-09-08 14:44:39作者:谭伦延

本文以 apps/mobile/changelog/0.2.0.md 为骨架,逐条拆解 Folo(AI RSS Reader)移动端 v0.2.0 的功能变更,并结合 apps/mobilepackages/internal/shared 中的真实源码说明其实现机制与配置字段。读完本文,你将掌握:如何用 hidePrivateSubscriptionsInTimeline 开关过滤时间线里的私密订阅、上拉连续阅读的触发阈值与导航行为、Discover 语言偏好如何跨会话记忆,以及 folo: / follow: 两种 URL Scheme 的深度链接分发规则。

一、v0.2.0 变更总览

v0.2.0 是 Folo 移动端早期版本中的一个功能迭代版本,变更集中在内容消费体验入口兼容两个方向,共包含 6 项变更:

类别 变更 关联版本号
新增 视频视图展示视频总时长 commit 2234b4b
新增 Timeline 中可隐藏私密订阅(Settings → General → Subscriptions → Hide Private) PR #3773
新增 上拉加载下一篇(pull-up to next),实现连续阅读 PR #3760
改进 Discover 页面记住用户语言偏好 commit 43d07e4
改进 同时兼容 folofollow 两种 URI Scheme commit 4fa171b

可以看到,v0.2.0 的三条“Shiny new things”围绕阅读效率(总时长、隐藏私密订阅、连续阅读)展开,两条“Improvements”则围绕使用记忆(语言)与生态兼容(双 Scheme)展开。下文将按实现深度逐条展开。

二、时间线隐藏私密订阅:一个“通用设置”字段贯穿到数据查询

2.1 设置项与默认值

该功能对应设置字段 hidePrivateSubscriptionsInTimeline,UI 路径为 Settings → General → Subscriptions → Hide Private(即应用的“通用设置 → 订阅相关分组”)。

在共享设置默认值 packages/internal/shared/src/settings/defaults.ts 中,它默认关闭:

// subscription
autoGroup: true,
hideAllReadSubscriptions: false,
hidePrivateSubscriptionsInTimeline: false,

类型定义位于 packages/internal/shared/src/settings/interface.ts,是 GeneralSettings 的普通布尔成员。值得注意的是,该设置与 translationtranslationMode 一样,在 packages/internal/shared/src/settings/constants.ts 中被标记为 SettingPaidLevels.Basic,属于免费基础档即可使用的偏好能力。

2.2 UI 开关 → 状态原子的数据流

在设置页 apps/mobile/src/modules/settings/routes/General.tsx 中,开关组件通过 useGeneralSettingKey("hidePrivateSubscriptionsInTimeline") 读取、通过 setGeneralSetting("hidePrivateSubscriptionsInTimeline", value) 写入:

const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey(
  "hidePrivateSubscriptionsInTimeline",
)
// ...
<Switch
  size="sm"
  value={hidePrivateSubscriptionsInTimeline}
  onValueChange={(value) => {
    setGeneralSetting("hidePrivateSubscriptionsInTimeline", value)
  }}
/>

底层是通用设置原子工厂 createSettingAtom("general", ...)(见 apps/mobile/src/atoms/settings/general.ts)。mobile 端在创建默认设置时会用 getDeviceLanguage() 回填 language,而 hidePrivateSubscriptionsInTimeline 等其余字段则继承共享默认值。

2.3 开关如何真正过滤 Timeline

真正影响查询的接线位于 apps/mobile/src/atoms/settings/general.tsuseFetchEntriesSettings():它把该开关与“只看未读”unreadOnly 一起打包成拉取条目所需的设置快照。

随后,在 apps/mobile/src/modules/screen/atoms.ts 中,这个布尔值被传入 useEntryIdsByView(view, hidePrivateSubscriptionsInTimeline),从而影响时间线视图的条目 ID 查询:

const options = useFetchEntriesSettings()
const { feedId, feedIdList, listId, inboxId, isCollection } = payload || {}
const { hidePrivateSubscriptionsInTimeline, unreadOnly } = options

const entryIdsByView = useEntryIdsByView(view, hidePrivateSubscriptionsInTimeline)

从源码结构看,开启后该开关会作为查询参数下发给“按视图取条目”的数据层,私密订阅源产生的条目将被排除在聚合时间线之外,而不是简单地在渲染层做隐藏——这正是它能作用于下拉刷新、未读角标、连续阅读所依赖的条目列表的前提。换句话说,无论用户是通过视图切换还是后续的“上拉读下一篇”(见下一节),其可见条目集合都一致地遵守这一偏好。

三、上拉加载下一篇(Pull-Up to Next):连续阅读的手势实现

PR #3760 为详情页引入的“pull-up to next”是本版本阅读体验的核心增强,它允许用户在某篇文章滚动到底部后继续上拉一段距离,直接切换到列表中的下一篇。

3.1 Hook 的设计

核心实现位于 apps/mobile/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation.tsx,对外暴露统一 Hook,并返回一套滚动事件与 UI 指示器。其 Props 与返回类型定义在 apps/mobile/src/modules/entry-content/pull-up-navigation/types.ts

export interface UsePullUpToNextProps {
  enabled?: boolean
  onRefresh?: (() => void) | undefined
  progressViewOffset?: number
}
  • enabled:是否启用(无下一篇时自动禁用);
  • onRefresh:触发切换的回调;
  • progressViewOffset:触发阈值位移,默认 70

3.2 阈值判定逻辑(源码拆解)

Hook 通过 onScroll 实时计算“越过底部”的距离 overOffset

const overOffset = e.contentOffset.y - e.contentSize.height + e.layoutMeasurement.height

const thresholdRatio = 0.95
if (overOffset > progressViewOffset) {
  // 越过阈值 → 触发重反馈(Heavy),置 refreshing=true
  if (!isOverThreshold.current && onRefresh) {
    Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
  }
  isOverThreshold.current = true
  setRefreshing(true)
} else if (overOffset < progressViewOffset * thresholdRatio) {
  // 回退到阈值 95% 以下 → 软反馈(Soft),解除触发态
  ...
}

其中 progressViewOffset 默认 70,而 thresholdRatio = 0.95 提供了约 5% 的回滞区间(hysteresis),避免在阈值边界反复抖动。手指结束拖动时才真正执行跳转(见 onScrollEndDrag):

const velocity = event.nativeEvent.velocity?.y || 0
if (isOverThreshold.current && velocity < 3) {
  onRefresh?.()
}

即:只有当拖拽已经越过阈值、且松手时的向上速度不快于 3 时才触发“下一篇”,防止快速甩动误触。此外 onScrollBeginDrag 中若起始位置越过底部超过 -50(正在快速下拉回看)会放弃本次拖动判定。该 Hook 还通过 EmptyGestureWrapper 预留了手势包装能力,当 enabled=false(如已是最后一篇)时,全部事件处理与指示器都会被替换为空实现,做到零开销、无副作用。

3.3 详情页如何完成“切换下一篇”

在详情页 apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx 中,先由当前列表的 entryIds 计算下一项 ID:

const nextEntryId = useMemo(() => {
  if (!entryIds) return
  const currentEntryIdx = entryIds.indexOf(entryId)
  return entryIds[currentEntryIdx + 1]
}, [entryId, entryIds])

再将 enabled: !!nextEntryId 与切换回调交给 Hook。回调通过 navigation.replaceControllerViewfade_from_bottom 动画(过渡时长 300ms)替换当前详情页:

const { ... } = usePullUpToNext({
  enabled: !!nextEntryId,
  onRefresh: useCallback(() => {
    if (!nextEntryId) return
    navigation.replaceControllerView(EntryDetailScreen, {
      entryId: nextEntryId,
      entryIds,
      view: viewType,
    }, {
      stackAnimation: "fade_from_bottom",
      transitionDuration: 300,
    })
  }, [entryIds, navigation, nextEntryId, viewType]),
})

上拉指示器区分平台实现:iOS 使用 PullUpIndicatorIos.tsx,Android 侧则有对应的 PullUpIndicatorAndroid.tsx 与独立入口 use-pull-up-navigation.android.tsx。这也说明连续阅读的触发参数(70 的位移阈值、0.95 回滞比、velocity < 3 的松手条件)在不同平台上保持一致,交互反馈则分别优化。

四、视频视图展示视频总时长

v0.2.0 中视频内容在列表与视图层开始展示更完整的媒体时长信息(commit 2234b4b)。移动端媒体元数据模型会携带以秒为单位的 duration_in_seconds,UI 层再将其格式化为可读时长。

以视频条目模板 apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx 为例,它从订阅源条目的媒体信息中读取时长,并使用 @follow/utils 提供的 formatDuration 进行格式化:

import { formatDuration } from "@follow/utils"
// ...
const seconds = media?.[0]?.duration_in_seconds  // 概念示意,实际取值见源码
if (seconds) {
  return formatDuration(Number.parseInt(seconds.toString()))
}

非视频的普通条目模板 EntryNormalItem.tsx 则会用 formatTimeToSeconds 将时长换算成“预计阅读 X 分钟”,供用户判断内容长度。可以推断:v0.2.0 的“视频总时长展示”是这一时长体系在**视频视图(Video View)**上的补齐——此前用户只看到播放进度,现在能在进入播放前就明确整段视频的总时长,便于在时间线上快速筛选长/短视频内容。

五、Discover 页面记住语言偏好

“Discover 页面记住语言偏好”(commit 43d07e4)解决了每次进入发现页都要重新选择内容语言的问题。其机制可从两处源码确认:

(1)语言字段与默认值绑定设备语言。apps/mobile/src/atoms/settings/general.ts 中,mobile 端创建默认通用设置时使用 getDeviceLanguage() 回填:

const createDefaultSettings = (): GeneralSettings => {
  const deviceLanguage = getDeviceLanguage()
  return {
    ...defaultGeneralSettings,
    language: deviceLanguage,
  }
}

用户在设置页或发现页切换过的语言会持久化到该 general.language 字段,之后每次冷启动都会沿用,而非退回系统默认语言。此外 language 还被列入 generalServerSyncWhiteListKeys(见 apps/mobile/src/atoms/settings/general.ts),可与服务端同步,保证多端一致。

(2)发现页请求本身携带语言并持久化缓存。 Discover 各 Tab 的数据请求会把语言作为查询参数传给服务端,例如 apps/mobile/src/modules/discover/api.ts 中的 getFeeds

return followClient.api.trending.getFeeds({
  language: lang,
  view,
  limit,
})

同时,发现页的推荐/趋势查询携带了 meta: { persist: true }(见 apps/mobile/src/modules/discover/Recommendations.tsxapps/mobile/src/modules/discover/Trending.tsx),并结合 staleTime: 1000 * 60 * 60 * 24(一天内复用缓存)。两者叠加后,用户在 Discover 选择的语言偏好便能在会话间稳定复现——这正是该“记忆”能力能被持久化到客户端查询层的关键。

六、folo / follow 双 URI Scheme 兼容

v0.2.0 增加了对两种 URL Scheme(folo:follow:)的兼容(commit 4fa171b),让历史存量链接(沿用 follow:)与应用新品牌/新标识(folo:)都能唤起 App。

深度链接的解析入口是 apps/mobile/src/hooks/useIntentHandler.tsextractParamsFromDeepLink 首先用标准的 new URL() 解析传入链接,并对协议做白名单校验:

const url = new URL(incomingUrl)
if (url.protocol !== "follow:" && url.protocol !== "folo:") return null

通过协议校验后,再按 hostname 分发到不同动作:

Scheme 主机名 动作 携带参数
add 打开 Follow 弹窗添加订阅/列表 idtypeurl/feed/list)、urlview
list 打开列表详情 idurlview
feed 打开订阅源详情 idurlview
refresh 失效当前用户会话(强制刷新)

解析成功后,handleIncomingUrl 会记录 previousIntentUrl 以去重,并通过 navigation.presentControllerView(FollowScreen, ...) 弹出订阅处理页(apps/mobile/src/hooks/useIntentHandler.ts)。整个处理流程同时覆盖冷启动(Linking.getInitialURL())与运行中唤起(Linking.addEventListener("url", ...))两种场景(apps/mobile/src/hooks/useIntentHandler.ts)。

因此,当服务端、第三方页面或历史书签仍然生成 follow://add?id=xxx 形式的链接时,v0.2.0 起它们将与新的 folo:// 链接获得完全一致的待遇;对外的分享/订阅导入链接也因此获得了平滑迁移窗口。可以推断,该改动同时需要系统侧将两种 Scheme 均声明到应用的 URI 注册配置中,以保证系统能将两类链接都路由到本 App 的 Intent 处理。

七、如何验证与继续跟进

如果你希望在自己环境中验证上述能力,建议按如下思路进行:

  1. 私密订阅过滤:在设置页打开 Settings → General → Subscriptions → Hide Private,对比同一视图下的条目数量变化;注意该字段默认值为 false(见 packages/internal/shared/src/settings/defaults.ts),关闭后可通过 setGeneralSetting 写入原子状态并即时刷新。
  2. 连续阅读:进入某一视图的第一篇文章,滚动到底部后继续上拉超过约 70px 的阈值并松手,观察 fade_from_bottom 切换动画;在最后一篇继续上拉则不会触发任何行为。
  3. 双 Scheme:分别以 follow://add?url=...folo://add?url=... 唤起应用,二者应都能弹出订阅弹窗。
  4. 语言记忆:在 Discover 页切换语言后重启应用,确认内容语言仍保持上次选择。

后续版本在此基础上继续演进:本仓库 apps/mobile/changelog 目录中还维护着自 v0.2.0 以来的完整迭代记录(可对照 apps/mobile/changelog 中的 0.3.x0.4.x0.5.x 等更高版本文件),相关设置字段在 packages/internal/shared/src/settings/interface.ts 中的演化也始终是理解各版本行为的可靠索引。

以上所有功能点均以 v0.2.0 的发布说明为纲、以仓库源码为实现证据,覆盖了“设置字段 → 状态原子 → 数据查询/手势交互 → 深度链接分发”的完整链路,可作为阅读或二次开发 Folo 移动端此版本功能时的直接参考。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391