Skip to content

Commit

Permalink
Document viewmodels
Browse files Browse the repository at this point in the history
  • Loading branch information
wingio committed Dec 5, 2023
1 parent 69ee788 commit 35cddca
Show file tree
Hide file tree
Showing 4 changed files with 92 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ class FeedTab : Tab {

},
onBoostClick = { postId ->
viewModel.toggleBoost(postId, realPost.hasBoosted ?: false, posts)
viewModel.toggleBoost(postId, realPost.hasBoosted ?: false)
},
onFavoriteClick = { postId ->
viewModel.toggleFavorite(postId, realPost.favorited ?: false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,56 +22,69 @@ import xyz.wingio.dimett.rest.utils.ifSuccessful
import xyz.wingio.dimett.ui.screens.main.MainScreen
import xyz.wingio.dimett.utils.openCustomTab


/**
* [ViewModel][ScreenModel] used by [xyz.wingio.dimett.ui.screens.auth.LoginScreen]
*/
class LoginViewModel(
private val mastodonRepository: MastodonRepository,
private val instanceManager: InstanceManager,
private val accountManager: AccountManager,
private val preferenceManager: PreferenceManager
) : ScreenModel {

// The following are to be used in the UI
var instance by mutableStateOf("")
var didError by mutableStateOf(false)
var nodeInfo by mutableStateOf(null as NodeInfo?)

var loginLoading by mutableStateOf(false)
var nodeInfoLoading by mutableStateOf(false)

// Version of `instance` that requests are actually sent to, not to be shown in the ui
private var instanceUrl: String? = null

// Whether or not the desired instance supports the Mastodon api, as that is all Dimett will support for now
val instanceIsMastodon: Boolean
get() = nodeInfo?.metadata?.features?.contains("mastodon_api") == true || nodeInfo?.software?.name == "mastodon"

/**
* Opens a Chrome custom tab with the OAuth authorization url for the desired instance
*/
fun login(context: Context) {
if (instance.isEmpty()) return
if (instance.isEmpty()) return // In case the login button isn't disabled for whatever reason
coroutineScope.launch(Dispatchers.IO) {
verifyInstance()?.let {
instanceUrl = it.url
// Construct a url according to https://docs.joinmastodon.org/client/authorized/#login
val url = URLBuilder("https://${it.url}/oauth/authorize").also { url ->
url.parameters.apply {
append("client_id", it.clientId)
append("redirect_uri", "dimett://oauth")
append("redirect_uri", "dimett://oauth") // Dimett should be the only app that supports this scheme
append("response_type", "code")
append("force_login", "true")
}
}.buildString() + "&scope=read+write+push"
context.openCustomTab(url, force = true)
context.openCustomTab(url, force = true) // We don't want this link opening up in a non-browser app
}
}
}

/**
* Loads some basic information about the desired instance using /.well-known/nodeinfo
*/
fun loadDetails() {
if (instance.isBlank()) return
if (instance.isBlank()) return // Can't load anything from a blank url
val url = fixUrl(instance)
nodeInfoLoading = true
nodeInfoLoading = true // Let the UI know that we're loading an instance's information

coroutineScope.launch(Dispatchers.IO) {
// Initial request is to /.well-known/nodeinfo, this only gives us the link to the actual node info
mastodonRepository.getNodeInfoLocation(url).fold(
success = {
it.links.firstOrNull()?.let { link ->
it.links.firstOrNull()?.let { link -> // We only need the first link
mastodonRepository.getNodeInfo(link.href).fold(
success = { node ->
nodeInfo = node
nodeInfo = node // Let the user know that we found this instance
nodeInfoLoading = false
},
fail = {
Expand All @@ -87,53 +100,63 @@ class LoginViewModel(
}
}

/**
* Creates the OAuth application on this instance, required to properly login
* ---
* *All created OAuth apps are saved to a local db*
*/
private suspend fun verifyInstance(): Instance? {
val url = fixUrl(instance)
if (instanceManager.exists(url)) return instanceManager[url]
if (instanceManager.exists(url)) return instanceManager[url] // Use the already existing OAuth app if it exists

var instance: Instance? = null
mastodonRepository.createApp(url).fold(
success = { app ->
instance = instanceManager.addInstance(
url,
app.clientId!!,
app.clientSecret!!,
nodeInfo?.metadata?.features ?: emptyList()
url = url,
clientId = app.clientId!!,
clientSecret = app.clientSecret!!,
features = nodeInfo?.metadata?.features ?: emptyList()
)
didError = false
},
fail = {
didError = true
didError = true // Update the user if something goes wrong
}
)
return instance
}

/**
* Handles all incoming intents for the LoginScreen, most importantly it handles the OAuth callback
*/
fun handleIntent(intent: Intent, navigator: Navigator) {
val data = intent.data ?: return
if (data.scheme != "dimett") return // only respond to dimett://
val code = data.getQueryParameter("code") ?: return
val data = intent.data ?: return // If the intent contains no data then it can be discarded
if (data.scheme != "dimett") return // Only respond to dimett://
val code = data.getQueryParameter("code") ?: return // This is necessary to generate the authorization token
val url = instanceUrl ?: return

coroutineScope.launch {
val instance = instanceManager[url] ?: return@launch
val instance = instanceManager[url] ?: return@launch // Make sure we have an OAuth app for this instance

loginLoading = true // Let the user know we're trying to log in

loginLoading = true
mastodonRepository.getToken(
instanceUrl = instance.url,
clientId = instance.clientId,
clientSecret = instance.clientSecret,
code = code
).ifSuccessful { token ->
mastodonRepository.verifyCredentials(instance.url, token.accessToken)
mastodonRepository.verifyCredentials(instance.url, token.accessToken) // Fetch the current user in order to see if the OAuth flow was successful
.ifSuccessful { user ->
accountManager.addAccount(
user = user,
token = token.accessToken,
token = token.accessToken, // Now all future requests can be authenticated for the user!! :)
instance = instance.url
)

preferenceManager.currentAccount = user.id
navigator.replaceAll(MainScreen())
preferenceManager.currentAccount = user.id // Switch to the newly added account
navigator.replaceAll(MainScreen()) // Replace the login screen with the main screen, don't let users shoot themselves in the foot
}
}

Expand All @@ -142,14 +165,24 @@ class LoginViewModel(
}
}

/**
* Returns a more standard url string
* - Removes the http(s) scheme
* - Extracts the domain from a user@domain pair
* - Trims non-ascii characters
*/
private fun fixUrl(url: String): String {
var s = url.replaceFirst("http://", "")
s = s.replaceFirst("https://", "")
val at = s.lastIndexOf('@')
if (at != -1) {
s = s.substring(at + 1)
}
return s.trim { it <= ' ' }
return url
.replaceFirst("http://", "")
.replaceFirst("https://", "")
.let {
// Only use the destination part of the url (The example.com from [email protected])
val at = it.lastIndexOf('@')
if (at != -1) {
it.substring(at + 1)
} else it
}
.trim { it <= ' ' } // Trim off any non-ascii characters
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,27 @@ import xyz.wingio.dimett.rest.dto.post.Post
import xyz.wingio.dimett.rest.utils.ApiResponse
import xyz.wingio.dimett.rest.utils.fold

/**
* [ViewModel][ScreenModel] used by [xyz.wingio.dimett.ui.screens.feed.FeedTab]
*/
class FeedViewModel(
private val repo: MastodonRepository
private val mastodonRepository: MastodonRepository
) : ScreenModel {

// Client side modifications (such as favorited status)
val modifiedPosts = mutableStateMapOf<String, Post>()

// Used for request pagination (infinite scroll)
// TODO: Possibly extract logic so that it can be reused with other requests
val posts = Pager(PagingConfig(pageSize = 20)) {
object : PagingSource<String, Post>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, Post> {
val next = params.key
val next = params.key // Should be the last post's id

return when (val response = repo.getFeed(max = next)) {
return when (val response = mastodonRepository.getFeed(max = next)) {
is ApiResponse.Success -> {
val nextKey = response.data.lastOrNull()?.id
if (next == null) modifiedPosts.clear()
if (next == null) modifiedPosts.clear() // Reset modifications when refreshing

LoadResult.Page(
data = response.data,
Expand Down Expand Up @@ -59,11 +65,14 @@ class FeedViewModel(
state.closestPageToPosition(it)?.prevKey
}
}
}.flow.cachedIn(coroutineScope)
}.flow.cachedIn(coroutineScope) // Binds requests to the lifecycle of this ScreenModel

/**
* (Un)favorite a post based on whether it was previously favorited
*/
fun toggleFavorite(id: String, favorited: Boolean) {
coroutineScope.launch {
val res = if (favorited) repo.unfavoritePost(id) else repo.favoritePost(id)
val res = if (favorited) mastodonRepository.unfavoritePost(id) else mastodonRepository.favoritePost(id)
res.fold(
success = {
modifiedPosts[id] = it
Expand All @@ -72,9 +81,12 @@ class FeedViewModel(
}
}

fun toggleBoost(id: String, boosted: Boolean, items: LazyPagingItems<Post>) {
/**
* (Un)boost a post based on whether it was previously boosted
*/
fun toggleBoost(id: String, boosted: Boolean) {
coroutineScope.launch {
val res = if (boosted) repo.unboostPost(id) else repo.boostPost(id)
val res = if (boosted) mastodonRepository.unboostPost(id) else mastodonRepository.boostPost(id)
res.fold(
success = {
if (boosted)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ import cafe.adriel.voyager.core.model.ScreenModel
import xyz.wingio.dimett.domain.db.entities.Account
import xyz.wingio.dimett.domain.manager.AccountManager

/**
* [ViewModel][ScreenModel] used by [xyz.wingio.dimett.ui.screens.main.MainScreen]
*/
class MainViewModel(
private val accounts: AccountManager
private val accountManager: AccountManager
) : ScreenModel {

/**
* The currently logged in account
*/
val account: Account?
get() = accounts.current
get() = accountManager.current

}

0 comments on commit 35cddca

Please sign in to comment.