Folo 移动端 v0.2.0 深度解读:Timeline 私密订阅过滤、上拉连续阅读与 folo/follow 双协议深度链接
本文以 apps/mobile/changelog/0.2.0.md 为骨架,逐条拆解 Folo(AI RSS Reader)移动端 v0.2.0 的功能变更,并结合
apps/mobile与packages/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 |
| 改进 | 同时兼容 folo 与 follow 两种 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 的普通布尔成员。值得注意的是,该设置与 translation、translationMode 一样,在 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.ts 的 useFetchEntriesSettings():它把该开关与“只看未读”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.replaceControllerView 以 fade_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.tsx 与 apps/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.ts。extractParamsFromDeepLink 首先用标准的 new URL() 解析传入链接,并对协议做白名单校验:
const url = new URL(incomingUrl)
if (url.protocol !== "follow:" && url.protocol !== "folo:") return null
通过协议校验后,再按 hostname 分发到不同动作:
| Scheme 主机名 | 动作 | 携带参数 |
|---|---|---|
add |
打开 Follow 弹窗添加订阅/列表 | id、type(url/feed/list)、url、view |
list |
打开列表详情 | id、url、view |
feed |
打开订阅源详情 | id、url、view |
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 处理。
七、如何验证与继续跟进
如果你希望在自己环境中验证上述能力,建议按如下思路进行:
- 私密订阅过滤:在设置页打开 Settings → General → Subscriptions → Hide Private,对比同一视图下的条目数量变化;注意该字段默认值为
false(见 packages/internal/shared/src/settings/defaults.ts),关闭后可通过setGeneralSetting写入原子状态并即时刷新。 - 连续阅读:进入某一视图的第一篇文章,滚动到底部后继续上拉超过约 70px 的阈值并松手,观察
fade_from_bottom切换动画;在最后一篇继续上拉则不会触发任何行为。 - 双 Scheme:分别以
follow://add?url=...与folo://add?url=...唤起应用,二者应都能弹出订阅弹窗。 - 语言记忆:在 Discover 页切换语言后重启应用,确认内容语言仍保持上次选择。
后续版本在此基础上继续演进:本仓库 apps/mobile/changelog 目录中还维护着自 v0.2.0 以来的完整迭代记录(可对照 apps/mobile/changelog 中的 0.3.x、0.4.x、0.5.x 等更高版本文件),相关设置字段在 packages/internal/shared/src/settings/interface.ts 中的演化也始终是理解各版本行为的可靠索引。
以上所有功能点均以 v0.2.0 的发布说明为纲、以仓库源码为实现证据,覆盖了“设置字段 → 状态原子 → 数据查询/手势交互 → 深度链接分发”的完整链路,可作为阅读或二次开发 Folo 移动端此版本功能时的直接参考。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00