server+native: unfurls ride the fetch service on iOS/Mac too (#1252)
- /api/group's member-gated read gains fetch_url (the web page already gets it injected) — native's FeatureGates carries it, nil-on-failed-read so a network blip never silently reroutes unfurls, '' meaning unfurl locally. - LChatAPI.unfurl prefers the fetch service (identity-free: no token rides along; card images come back as signed /img proxy URLs), falling back to the instance's own unfurler on transport failure — the web bridge's exact semantics, incl. treating a decoded ok:false as a real answer. - ChatFeedModel stops caching unfurl failures forever: successes cache for the model's lifetime, failures retry after 60s — the recipient-side 'card never appears until thread reopen' residue, web-parity with addLinkPreview's eviction while still stopping re-render retry storms. Shared LChatCore, so Mac and iOS land together.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+63
−23
ios/LChatCore/Package.resolved+0 −9ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+20 −6ios/LChatCore/Sources/LChatCore/LChatAPI.swift+36 −8ios/LChatCore/Sources/LChatCore/ThreadStore.swift+4 −0main.go+3 −0
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved| index fe25d1c..d82f91f 100644 |
| --- a/ios/LChatCore/Package.resolved |
| +++ b/ios/LChatCore/Package.resolved |
| @@ -1,14 +1,5 @@ |
| { |
| "pins" : [ |
| - { |
| - "identity" : "sparkle", |
| - "kind" : "remoteSourceControl", |
| - "location" : "https://github.com/sparkle-project/Sparkle", |
| - "state" : { |
| - "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7", |
| - "version" : "2.9.4" |
| - } |
| - }, |
| { |
| "identity" : "swift-cmark", |
| "kind" : "remoteSourceControl", |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index b8bbb80..804484e 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -269,25 +269,39 @@ public final class ChatFeedModel: ObservableObject { |
| return plain |
| } |
| - // Unfurl results, cached per URL for the model's lifetime — nil entries |
| - // remember failures so a dead link isn't re-fetched on every re-render. |
| - private var unfurlCache: [URL: LinkPreview?] = [:] |
| + // Unfurl results: successes cache for the model's lifetime; failures only |
| + // briefly. Caching nil forever meant a recipient who raced the poster's |
| + // first fetch (or crossed a network blip) never got the card until they |
| + // reopened the thread — the web evicts failures for the same reason |
| + // (chat.js addLinkPreview); the TTL is what stops re-render retry storms. |
| + private var unfurlCache: [URL: LinkPreview] = [:] |
| + private var unfurlEmptyAt: [URL: Date] = [:] |
| private var unfurlInFlight: Set<URL> = [] |
| + private static let unfurlRetryAfter: TimeInterval = 60 |
| /// Synchronous peek at the unfurl cache — height pre-measurement needs to |
| /// know NOW whether a card will render (.some(nil) = known-empty). |
| public func cachedUnfurl(_ url: URL) -> LinkPreview?? { |
| - unfurlCache[url] |
| + if let hit = unfurlCache[url] { return hit } |
| + if let at = unfurlEmptyAt[url], Date().timeIntervalSince(at) < Self.unfurlRetryAfter { |
| + return .some(nil) |
| + } |
| + return .none |
| } |
| /// The link-preview card for a URL (cached; nil = nothing to show). |
| public func unfurl(_ url: URL) async -> LinkPreview? { |
| - if let cached = unfurlCache[url] { return cached } |
| + if let known = cachedUnfurl(url) { return known } |
| guard let api, !unfurlInFlight.contains(url) else { return nil } |
| unfurlInFlight.insert(url) |
| defer { unfurlInFlight.remove(url) } |
| let preview = await api.unfurl(url) |
| - unfurlCache[url] = preview |
| + if let preview { |
| + unfurlCache[url] = preview |
| + unfurlEmptyAt[url] = nil |
| + } else { |
| + unfurlEmptyAt[url] = Date() |
| + } |
| return preview |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift| index e9a947d..6128800 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| @@ -376,6 +376,12 @@ public struct LChatAPI { |
| /// (unfurl.go): the server fetches the page to build the card, so it sees |
| /// the URL (never message text). Returns nil when the fetch failed or the |
| /// page offered nothing worth showing. |
| + /// The shared fetch service this instance points at, if any — set from |
| + /// FeatureGates by the store. Unfurls prefer it (identity-free: no token |
| + /// rides along, and card images come back as its signed /img proxy URLs), |
| + /// falling back to the instance's own unfurler on any transport failure. |
| + public var fetchURL: String = "" |
| + |
| public func unfurl(_ url: URL) async -> LinkPreview? { |
| struct Resp: Decodable { |
| let url: String? |
| @@ -384,12 +390,25 @@ public struct LChatAPI { |
| let image: String? |
| let ok: Bool? |
| } |
| - let items = [ |
| - URLQueryItem(name: "token", value: token), |
| - URLQueryItem(name: "url", value: url.absoluteString), |
| - ] |
| - guard let r = try? await getJSON("/api/unfurl", Resp.self, query: items), |
| - r.ok == true, let title = r.title, !title.isEmpty else { return nil } |
| + var r: Resp? |
| + if !fetchURL.isEmpty, |
| + var c = URLComponents(string: fetchURL + "/api/unfurl") { |
| + c.queryItems = [URLQueryItem(name: "url", value: url.absoluteString)] |
| + if let u = c.url, let (data, resp) = try? await session.data(from: u), |
| + (resp as? HTTPURLResponse)?.statusCode == 200 { |
| + // A decoded answer — even ok:false — is real (the page offered |
| + // nothing); only transport failures fall through to the instance. |
| + r = try? JSONDecoder().decode(Resp.self, from: data) |
| + } |
| + } |
| + if r == nil { |
| + let items = [ |
| + URLQueryItem(name: "token", value: token), |
| + URLQueryItem(name: "url", value: url.absoluteString), |
| + ] |
| + r = try? await getJSON("/api/unfurl", Resp.self, query: items) |
| + } |
| + guard let r, r.ok == true, let title = r.title, !title.isEmpty else { return nil } |
| return LinkPreview(url: URL(string: r.url ?? "") ?? url, title: title, |
| description: r.description ?? "", imageURL: URL(string: r.image ?? "")) |
| } |
| @@ -438,14 +457,23 @@ public struct LChatAPI { |
| public let botsEnabled: Bool |
| public let appsEnabled: Bool |
| public let aiEnabled: Bool |
| + /// The fetch service this instance prefers for unfurls (docs/fetch.md): |
| + /// "" = the instance says unfurl locally; nil = the gates read itself |
| + /// failed (keep whatever routing we already hold — a network blip must |
| + /// not silently reroute unfurls). |
| + public let fetchURL: String? |
| } |
| public func featureGates() async -> FeatureGates { |
| - struct Resp: Codable { let bots_enabled: Bool?; let apps_enabled: Bool?; let ai_enabled: Bool? } |
| + struct Resp: Codable { |
| + let bots_enabled: Bool?; let apps_enabled: Bool?; let ai_enabled: Bool? |
| + let fetch_url: String? |
| + } |
| let r = try? await getJSON("/api/group", Resp.self) |
| return FeatureGates(botsEnabled: r?.bots_enabled ?? true, |
| appsEnabled: r?.apps_enabled ?? true, |
| - aiEnabled: r?.ai_enabled ?? true) |
| + aiEnabled: r?.ai_enabled ?? true, |
| + fetchURL: r == nil ? nil : (r?.fetch_url ?? "")) |
| } |
| /// The instance's public branding photo bytes, or nil if none is set. Served |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index 8eb8d19..afadeb0 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -181,6 +181,10 @@ public final class ThreadStore: ObservableObject { |
| let gates = await gatesAsync |
| botsEnabled = gates.botsEnabled |
| appsEnabled = gates.appsEnabled |
| + // Route unfurls through the instance's preferred fetch service. nil = |
| + // the gates read failed — keep the routing we hold (a blip must not |
| + // reroute); "" is a real answer (the instance says unfurl locally). |
| + if let fetchURL = gates.fetchURL { api.fetchURL = fetchURL } |
| // nil = fetch failed — keep the held set rather than "unblocking" |
| // everyone over a network blip. |
| if let blocked = await blocksAsync { blockedPeople = Set(blocked) } |
diff --git a/main.go b/main.go| index f91f924..a3137ae 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -4238,6 +4238,9 @@ func (s *Server) getGroup(w http.ResponseWriter, r *http.Request) { |
| out["bots_enabled"] = s.db.botsEnabled() |
| out["apps_enabled"] = s.db.appsEnabled() |
| out["member_apps_enabled"] = s.db.memberAppsEnabled() |
| + // The fetch service this instance prefers for unfurls (docs/fetch.md). |
| + // The web page gets it injected (__FETCH_URL__); native reads it here. |
| + out["fetch_url"] = s.fetchURL |
| } |
| writeJSON(w, http.StatusOK, out) |
| } |