feat: import follow list with Namecoin resolution during signup

Add a new signup step that lets users bootstrap their feed by importing
another profile's follow list. Supports all identifier types:

  - npub1... (NIP-19 bech32)
  - 64-char hex pubkey
  - alice@example.com (NIP-05 HTTP)
  - alice@example.bit / d/name / id/name (Namecoin blockchain)

Flow: Enter identifier → Resolve → Fetch kind 3 → Preview with
select/deselect → Apply follows → Done. Users can search multiple
profiles before continuing.

Changes to existing files:
  - NamecoinNameResolver: add resolveDetailed() + NamecoinResolveOutcome
  - NamecoinNameService: add resolveDetailed(), resolvePubkey()
  - AccountSessionManager: add isNewAccount flag, finishNewAccountSetup()
  - AccountScreen: route new accounts to ImportFollowListSection

New files:
  - FollowListImporter: identifier resolution + kind 3 fetch + parsing
  - ImportFollowListViewModel: state machine for the import flow
  - ImportFollowListScreen: UI with preview, select/deselect, search again
  - FollowListImporterTest: 14 unit tests
This commit is contained in:
M
2026-03-08 15:38:54 +11:00
parent 46d54d2826
commit 8456b38dea
8 changed files with 1447 additions and 5 deletions
@@ -27,6 +27,36 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
/** Detailed outcome of a Namecoin resolution attempt. */
sealed class NamecoinResolveOutcome {
data class Success(
val result: NamecoinNostrResult,
) : NamecoinResolveOutcome()
/** The name does not exist on the Namecoin blockchain. */
data class NameNotFound(
val name: String,
) : NamecoinResolveOutcome()
/** The name exists but has no valid "nostr" field in its value. */
data class NoNostrField(
val name: String,
) : NamecoinResolveOutcome()
/** All ElectrumX servers were unreachable. */
data class ServersUnreachable(
val message: String,
) : NamecoinResolveOutcome()
/** The identifier could not be parsed as a Namecoin name. */
data class InvalidIdentifier(
val identifier: String,
) : NamecoinResolveOutcome()
/** Timed out waiting for a response. */
data object Timeout : NamecoinResolveOutcome()
}
/**
* Result of resolving a Namecoin name to Nostr identity data.
*/
@@ -88,6 +118,18 @@ class NamecoinNameResolver(
}
}
/**
* Resolve with detailed outcome for error reporting in UI flows.
*/
suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome {
val parsed =
parseIdentifier(identifier)
?: return NamecoinResolveOutcome.InvalidIdentifier(identifier)
val result =
withTimeoutOrNull(lookupTimeoutMs) { performLookupDetailed(parsed) }
return result ?: NamecoinResolveOutcome.Timeout
}
// ── Identifier Parsing ─────────────────────────────────────────────
/**
@@ -175,6 +217,39 @@ class NamecoinNameResolver(
}
}
private suspend fun performLookupDetailed(parsed: ParsedIdentifier): NamecoinResolveOutcome {
val nameResult: NameShowResult
try {
nameResult =
electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider())
?: return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName)
} catch (e: NamecoinLookupException.NameNotFound) {
return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName)
} catch (e: NamecoinLookupException.NameExpired) {
return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName)
} catch (e: NamecoinLookupException.ServersUnreachable) {
return NamecoinResolveOutcome.ServersUnreachable(
e.message ?: "All ElectrumX servers unreachable",
)
}
val valueJson =
tryParseJson(nameResult.value)
?: return NamecoinResolveOutcome.NoNostrField(parsed.namecoinName)
val nostrResult =
when (parsed.namespace) {
Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed)
Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed)
}
return if (nostrResult != null) {
NamecoinResolveOutcome.Success(nostrResult)
} else {
NamecoinResolveOutcome.NoNostrField(parsed.namecoinName)
}
}
/**
* Extract Nostr data from a `d/` domain value.
*