eleven / messenger Public

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>
Paul Campbell c8a2b94ec75bfaca963cc87e8365d01434304f37 parent cb77f1f
5 files changed, +63 −23
  • ios/LChatCore/Package.resolved +0 −9
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +20 −6
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +36 −8
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +4 −0
  • main.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)
}