Vectrum
Sign In

Add likes and lists to the Android app

The repository screen gets Like and Save keys. Save opens a sheet with the
lists you can change (yours and your organizations'), ticked where the
repository is, and makes a new list with it on the spot. Profiles and the
You tab show repositories, lists and liked repositories in tabs, and a
list's screen removes repositories, renames the list, makes it public or
private, or deletes it. /lists/<owner>/<slug> links open in the app.

/api/v1 gains POST …/like, GET and POST …/lists, POST /lists and
GET, PATCH, DELETE /lists/<owner>/<slug>; repositories carry their likes,
and profiles their lists and liked repositories.

RefreshOnReturn kept its "first show" flag in remember, which is lost
while another screen is on top, so screens never reloaded on return. It
is saved with the screen's state now.

The app is version 1.2.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 1 parent 9e2c1b7commit2a91e07

Showing 14 changed files with 838 additions and 26 deletions.

+2−2apps/mobile/app/build.gradle.ktsView File
@@ -12,8 +12,8 @@ android {
1212 applicationId = "cc.vectrum.app"
1313 minSdk = 26
1414 targetSdk = 36
15- versionCode = 2
16- versionName = "1.1.0"
15+ versionCode = 3
16+ versionName = "1.2.0"
1717 }
1818
1919 buildTypes {
+34−0apps/mobile/app/src/main/java/cc/vectrum/app/data/Api.ktView File
@@ -169,6 +169,40 @@ class Api(val http: OkHttpClient, private val server: HttpUrl, private val token
169169 suspend fun run(owner: String, name: String, number: Int): RunDetail = get("${repo(owner, name)}/runs/$number")
170170 suspend fun releases(owner: String, name: String): ReleasesData = get("${repo(owner, name)}/releases")
171171
172+ suspend fun like(owner: String, name: String, liked: Boolean): LikeResult =
173+ send("POST", "${repo(owner, name)}/like", buildJsonObject { put("liked", JsonPrimitive(liked)) })
174+ suspend fun saveTargets(owner: String, name: String): SaveTargets = get("${repo(owner, name)}/lists")
175+ suspend fun setSaved(owner: String, name: String, listId: String, saved: Boolean): SaveTargets =
176+ send("POST", "${repo(owner, name)}/lists", buildJsonObject { put("list", JsonPrimitive(listId)); put("saved", JsonPrimitive(saved)) })
177+
178+ /** A new list for [owner] (you, or one of your organizations), with the repository [repo] ("owner/name") in it if given. */
179+ suspend fun createList(owner: String, name: String, private: Boolean, description: String = "", repo: String? = null): RepoListInfo =
180+ send<ListResponse>(
181+ "POST",
182+ "lists",
183+ buildJsonObject {
184+ put("owner", JsonPrimitive(owner))
185+ put("name", JsonPrimitive(name))
186+ put("description", JsonPrimitive(description))
187+ put("visibility", JsonPrimitive(if (private) "private" else "public"))
188+ if (repo != null) put("repo", JsonPrimitive(repo))
189+ },
190+ ).list
191+ suspend fun list(owner: String, slug: String): ListDetail = get("lists/$owner/$slug")
192+ suspend fun updateList(owner: String, slug: String, name: String, description: String, private: Boolean): RepoListInfo =
193+ send<ListResponse>(
194+ "PATCH",
195+ "lists/$owner/$slug",
196+ buildJsonObject {
197+ put("name", JsonPrimitive(name))
198+ put("description", JsonPrimitive(description))
199+ put("visibility", JsonPrimitive(if (private) "private" else "public"))
200+ },
201+ ).list
202+ suspend fun deleteList(owner: String, slug: String) {
203+ call(request(url("lists/$owner/$slug")).delete().build())
204+ }
205+
172206 companion object {
173207 private val JSON = "application/json; charset=utf-8".toMediaType()
174208
+41−1apps/mobile/app/src/main/java/cc/vectrum/app/data/Models.ktView File
@@ -39,6 +39,7 @@ data class RepoSummary(
3939 val createdAt: Long = 0,
4040 val updatedAt: Long = 0,
4141 val empty: Boolean = false,
42+ val likes: Int = 0,
4243 ) {
4344 val isPrivate: Boolean get() = visibility == "private"
4445 val fullName: String get() = "$owner/$name"
@@ -90,7 +91,43 @@ data class HomeData(
9091 )
9192
9293 @Serializable data class ReposData(val repos: List<RepoSummary>)
93- @Serializable data class ProfileData(val user: User, val repos: List<RepoSummary>)
94+ @Serializable
95+ data class ProfileData(
96+ val user: User,
97+ val repos: List<RepoSummary>,
98+ val isOrg: Boolean = false,
99+ val lists: List<RepoListInfo> = emptyList(),
100+ /** The repositories a person likes (none for organizations). */
101+ val liked: List<RepoSummary> = emptyList(),
102+ /** Whether the viewer may make and change this account's lists. */
103+ val canManageLists: Boolean = false,
104+ )
105+
106+ /** A list of repositories that a person or an organization keeps, public or private. */
107+ @Serializable
108+ data class RepoListInfo(
109+ val id: String,
110+ val owner: String,
111+ val ownerIsOrg: Boolean = false,
112+ val name: String,
113+ val slug: String,
114+ val description: String = "",
115+ val visibility: String = "public",
116+ val createdAt: Long = 0,
117+ val updatedAt: Long = 0,
118+ /** How many of its repositories the viewer may see. */
119+ val repoCount: Int = 0,
120+ /** In the Save sheet: whether the repository is in it. */
121+ val saved: Boolean = false,
122+ ) {
123+ val isPrivate: Boolean get() = visibility == "private"
124+ }
125+
126+ /** The lists a repository can be saved to (the viewer's and their organizations'), for the Save sheet. */
127+ @Serializable data class SaveTargets(val owners: List<String> = emptyList(), val lists: List<RepoListInfo> = emptyList())
128+ @Serializable data class ListDetail(val list: RepoListInfo, val repos: List<RepoSummary> = emptyList(), val canManage: Boolean = false)
129+ @Serializable data class ListResponse(val list: RepoListInfo)
130+ @Serializable data class LikeResult(val liked: Boolean, val likes: Int)
94131
95132 @Serializable data class TreeEntry(val name: String, val path: String, val type: String, val size: Long? = null) {
96133 val isDir: Boolean get() = type == "tree"
@@ -106,6 +143,9 @@ data class RepoDetail(
106143 val repo: RepoSummary,
107144 val access: String,
108145 val canWrite: Boolean,
146+ /** Whether the viewer likes it, and whether it's in any of their lists. */
147+ val liked: Boolean = false,
148+ val saved: Boolean = false,
109149 val defaultBranch: String? = null,
110150 val branches: List<String> = emptyList(),
111151 val tags: List<String> = emptyList(),
+9−0apps/mobile/app/src/main/java/cc/vectrum/app/ui/App.ktView File
@@ -64,6 +64,7 @@ import cc.vectrum.app.ui.screens.ExploreScreen
6464 import cc.vectrum.app.ui.screens.FileScreen
6565 import cc.vectrum.app.ui.screens.HomeScreen
6666 import cc.vectrum.app.ui.screens.IssueScreen
67+ import cc.vectrum.app.ui.screens.ListScreen
6768 import cc.vectrum.app.ui.screens.MeScreen
6869 import cc.vectrum.app.ui.screens.NewIssueScreen
6970 import cc.vectrum.app.ui.screens.ProfileScreen
@@ -92,6 +93,7 @@ import kotlinx.serialization.Serializable
9293 @Serializable data class NewIssueRoute(val owner: String, val name: String)
9394 @Serializable data class PullRoute(val owner: String, val name: String, val number: Int)
9495 @Serializable data class RunRoute(val owner: String, val name: String, val number: Int)
96+ @Serializable data class ListRoute(val owner: String, val slug: String)
9597
9698 val LocalApi = staticCompositionLocalOf<Api?> { null }
9799 val LocalSession = staticCompositionLocalOf<Session?> { null }
@@ -112,6 +114,7 @@ class Nav(val controller: NavHostController, private val openUrl: (String) -> Un
112114 fun pull(owner: String, name: String, number: Int) = controller.navigate(PullRoute(owner, name, number))
113115 fun run(owner: String, name: String, number: Int) = controller.navigate(RunRoute(owner, name, number))
114116 fun profile(username: String) = controller.navigate(ProfileRoute(username))
117+ fun list(owner: String, slug: String) = controller.navigate(ListRoute(owner, slug))
115118
116119 /** Switches to a top-level tab (Home, Explore, You), keeping each tab's state. */
117120 fun tab(route: Any) = controller.navigate(route) {
@@ -153,6 +156,11 @@ class Nav(val controller: NavHostController, private val openUrl: (String) -> Un
153156 if (parts.size == 1) {
154157 profile(parts[0]); return true
155158 }
159+ // /lists/<owner>/<slug>; "lists" can't be anyone's name.
160+ if (parts[0] == "lists") {
161+ if (parts.size != 3) return false
162+ list(parts[1], parts[2]); return true
163+ }
156164 val (owner, name) = parts[0] to parts[1].removeSuffix(".git")
157165 val rest = parts.drop(2)
158166 when (rest.firstOrNull()) {
@@ -230,6 +238,7 @@ fun MainShell(session: Session, api: Api, theme: ThemeMode, onTheme: (ThemeMode)
230238 }
231239 composable<PullRoute> { val r = it.toRoute<PullRoute>(); PullScreen(r.owner, r.name, r.number) }
232240 composable<RunRoute> { val r = it.toRoute<RunRoute>(); RunScreen(r.owner, r.name, r.number) }
241+ composable<ListRoute> { val r = it.toRoute<ListRoute>(); ListScreen(r.owner, r.slug) }
233242 }
234243
235244 if (topLevel) {
+3−2apps/mobile/app/src/main/java/cc/vectrum/app/ui/Loader.ktView File
@@ -2,7 +2,7 @@ package cc.vectrum.app.ui
22
33 import androidx.compose.runtime.Composable
44 import androidx.compose.runtime.getValue
5- import androidx.compose.runtime.remember
5+ import androidx.compose.runtime.saveable.rememberSaveable
66 import androidx.compose.runtime.rememberUpdatedState
77 import androidx.lifecycle.compose.LifecycleResumeEffect
88 import androidx.compose.runtime.mutableStateListOf
@@ -166,7 +166,8 @@ fun <T> rememberPager(key: String, fetch: suspend (page: Int) -> Pair<List<T>, B
166166 /** Runs `onReturn` when the screen comes back into view (after a screen on top of it closes), not on first show. */
167167 @Composable
168168 fun RefreshOnReturn(onReturn: () -> Unit) {
169- val first = remember { mutableStateOf(true) }
169+ // Saved with the screen's state: it leaves the composition while another screen is on top of it.
170+ val first = rememberSaveable { mutableStateOf(true) }
170171 val action by rememberUpdatedState(onReturn)
171172 LifecycleResumeEffect(Unit) {
172173 if (first.value) first.value = false else action()
+4−0apps/mobile/app/src/main/java/cc/vectrum/app/ui/icons/Icons.ktView File
@@ -111,5 +111,9 @@ object VIcons {
111111 val Key = icon("Key", Stroke("M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"), dot(16.5f, 7.5f, 1.5f))
112112 val Image = icon("Image", Stroke(rect(3f, 3f, 18f, 18f, 3f), fill = 0.08f), Stroke(circle(9f, 9f, 2f)), Stroke("m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21"))
113113 val Refresh = icon("Refresh", Stroke("M3 12a9 9 0 0 1 15.36-6.36L21 8"), Stroke("M21 3v5h-5"), Stroke("M21 12a9 9 0 0 1-15.36 6.36L3 16"), Stroke("M3 21v-5h5"))
114+ val Heart = icon("Heart", Stroke("M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"))
115+ val HeartFilled = icon("HeartFilled", Fill("M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"), Stroke("M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"))
116+ val Bookmark = icon("Bookmark", Stroke("m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"))
117+ val BookmarkFilled = icon("BookmarkFilled", Fill("m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"), Stroke("m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"))
114118 val Send = icon("Send", Stroke("M14.54 21.69a.5.5 0 0 0 .94-.03l6.5-19a.5.5 0 0 0-.64-.64l-19 6.5a.5.5 0 0 0-.03.94l7.93 3.18a2 2 0 0 1 1.11 1.11z", fill = 0.1f), Stroke("m21.85 2.15-10.94 10.94"))
115119 }
+450−0apps/mobile/app/src/main/java/cc/vectrum/app/ui/screens/ListScreens.ktaddedView File
@@ -0,0 +1,450 @@
1+ package cc.vectrum.app.ui.screens
2+
3+ import androidx.compose.foundation.layout.Arrangement
4+ import androidx.compose.foundation.layout.Box
5+ import androidx.compose.foundation.layout.Column
6+ import androidx.compose.foundation.layout.ExperimentalLayoutApi
7+ import androidx.compose.foundation.layout.FlowRow
8+ import androidx.compose.foundation.layout.Row
9+ import androidx.compose.foundation.layout.Spacer
10+ import androidx.compose.foundation.layout.fillMaxSize
11+ import androidx.compose.foundation.layout.fillMaxWidth
12+ import androidx.compose.foundation.layout.height
13+ import androidx.compose.foundation.layout.heightIn
14+ import androidx.compose.foundation.layout.padding
15+ import androidx.compose.foundation.layout.size
16+ import androidx.compose.foundation.layout.width
17+ import androidx.compose.foundation.lazy.rememberLazyListState
18+ import androidx.compose.foundation.rememberScrollState
19+ import androidx.compose.foundation.shape.RoundedCornerShape
20+ import androidx.compose.foundation.verticalScroll
21+ import androidx.compose.material3.Icon
22+ import androidx.compose.material3.Text
23+ import androidx.compose.runtime.Composable
24+ import androidx.compose.runtime.LaunchedEffect
25+ import androidx.compose.runtime.getValue
26+ import androidx.compose.runtime.mutableStateOf
27+ import androidx.compose.runtime.remember
28+ import androidx.compose.runtime.rememberCoroutineScope
29+ import androidx.compose.runtime.saveable.rememberSaveable
30+ import androidx.compose.runtime.setValue
31+ import androidx.compose.ui.Alignment
32+ import androidx.compose.ui.Modifier
33+ import androidx.compose.ui.text.style.TextOverflow
34+ import androidx.compose.ui.unit.dp
35+ import cc.vectrum.app.data.RepoListInfo
36+ import cc.vectrum.app.data.SaveTargets
37+ import cc.vectrum.app.ui.Load
38+ import cc.vectrum.app.ui.LocalApi
39+ import cc.vectrum.app.ui.LocalNav
40+ import cc.vectrum.app.ui.LocalSession
41+ import cc.vectrum.app.ui.RefreshOnReturn
42+ import cc.vectrum.app.ui.components.ButtonKind
43+ import cc.vectrum.app.ui.components.EmptyState
44+ import cc.vectrum.app.ui.components.Engraved
45+ import cc.vectrum.app.ui.components.Field
46+ import cc.vectrum.app.ui.components.IconText
47+ import cc.vectrum.app.ui.components.IconTile
48+ import cc.vectrum.app.ui.components.Loaded
49+ import cc.vectrum.app.ui.components.LocalToaster
50+ import cc.vectrum.app.ui.components.Pill
51+ import cc.vectrum.app.ui.components.Plate
52+ import cc.vectrum.app.ui.components.PressableRow
53+ import cc.vectrum.app.ui.components.RoundButton
54+ import cc.vectrum.app.ui.components.Rule
55+ import cc.vectrum.app.ui.components.Section
56+ import cc.vectrum.app.ui.components.SegmentOption
57+ import cc.vectrum.app.ui.components.Segmented
58+ import cc.vectrum.app.ui.components.Tone
59+ import cc.vectrum.app.ui.components.TopBar
60+ import cc.vectrum.app.ui.components.VButton
61+ import cc.vectrum.app.ui.components.VSheet
62+ import cc.vectrum.app.ui.components.isScrolled
63+ import cc.vectrum.app.ui.describe
64+ import cc.vectrum.app.ui.icons.VIcons
65+ import cc.vectrum.app.ui.plural
66+ import cc.vectrum.app.ui.rememberLoader
67+ import cc.vectrum.app.ui.theme.Vectrum
68+ import cc.vectrum.app.ui.theme.well
69+ import cc.vectrum.app.ui.timeAgo
70+ import kotlinx.coroutines.launch
71+
72+ // Likes and lists of repositories, like the website's (lib/lists.ts on the server): a person likes a
73+ // repository once; people and organizations keep public or private lists of repositories.
74+
75+ /** The repository's Like and Save keys, with how many people like it. */
76+ @Composable
77+ fun LikeSaveRow(liked: Boolean, likes: Int, saved: Boolean, onLike: () -> Unit, onSave: () -> Unit) {
78+ val c = Vectrum.colors
79+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
80+ VButton(if (liked) "Liked" else "Like", onLike, icon = if (liked) VIcons.HeartFilled else VIcons.Heart, small = true)
81+ Text(plural(likes, "like"), style = Vectrum.type.readout, color = c.ink2)
82+ Spacer(Modifier.weight(1f))
83+ VButton(if (saved) "Saved" else "Save", onSave, icon = if (saved) VIcons.BookmarkFilled else VIcons.Bookmark, small = true)
84+ }
85+ }
86+
87+ /**
88+ * The lists the repository can go in (yours, then your organizations'), ticked where it is, and a
89+ * field to make a new list with it. [onChanged] says whether it's in any list now.
90+ */
91+ @Composable
92+ fun SaveSheet(owner: String, name: String, onDismiss: () -> Unit, onChanged: (saved: Boolean) -> Unit) {
93+ val api = LocalApi.current!!
94+ val session = LocalSession.current!!
95+ val toaster = LocalToaster.current
96+ val scope = rememberCoroutineScope()
97+ val loader = rememberLoader("save:$owner/$name") { api.saveTargets(owner, name) }
98+ val c = Vectrum.colors
99+ var newName by rememberSaveable { mutableStateOf("") }
100+ var newPrivate by rememberSaveable { mutableStateOf(false) }
101+ var newOwner by rememberSaveable { mutableStateOf(session.user.username) }
102+ var creating by remember { mutableStateOf(false) }
103+ // Lists may have changed elsewhere since the sheet was last open.
104+ LaunchedEffect(Unit) { if (loader.state is Load.Ready) loader.load(refresh = true) }
105+
106+ fun show(targets: SaveTargets) {
107+ loader.set(targets)
108+ onChanged(targets.lists.any { it.saved })
109+ }
110+
111+ VSheet("Save to a list", onDismiss) {
112+ Column(Modifier.heightIn(max = 600.dp).verticalScroll(rememberScrollState())) {
113+ when (val s = loader.state) {
114+ is Load.Loading -> Text("Loading…", style = Vectrum.type.meta, color = c.ink3, modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp))
115+ is Load.Failed -> Text(s.message, style = Vectrum.type.body, color = c.red, modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp))
116+ is Load.Ready -> {
117+ val targets = s.data
118+ if (targets.lists.isEmpty()) {
119+ Text(
120+ "Lists gather repositories to find them again or share them. Make your first one below.",
121+ style = Vectrum.type.body,
122+ color = c.ink2,
123+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
124+ )
125+ }
126+ for (group in targets.owners) {
127+ val lists = targets.lists.filter { it.owner == group }
128+ if (lists.isEmpty()) continue
129+ if (targets.owners.size > 1) {
130+ Engraved(if (group == session.user.username) "Your lists" else group, Modifier.padding(start = 24.dp, end = 24.dp, top = 12.dp, bottom = 4.dp))
131+ }
132+ for (list in lists) {
133+ SaveTargetRow(list) {
134+ val saved = !list.saved
135+ // Ticks at once; what the server answers replaces it.
136+ show(targets.copy(lists = targets.lists.map { if (it.id == list.id) it.copy(saved = saved) else it }))
137+ scope.launch {
138+ try {
139+ show(api.setSaved(owner, name, list.id, saved))
140+ toaster.show(if (saved) "Saved to ${list.name}." else "Removed from ${list.name}.")
141+ } catch (e: Exception) {
142+ toaster.error(describe(e))
143+ loader.load(refresh = true)
144+ }
145+ }
146+ }
147+ }
148+ }
149+ Spacer(Modifier.height(12.dp))
150+ Rule(Modifier.padding(horizontal = 24.dp))
151+ Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
152+ Engraved("New list", Modifier.padding(start = 4.dp))
153+ Field(newName, { newName = it.take(60) }, placeholder = "e.g. Tools I use")
154+ if (targets.owners.size > 1) {
155+ Segmented(targets.owners.map { SegmentOption(it, it) }, selected = newOwner, onSelect = { newOwner = it }, scrollable = true)
156+ }
157+ Segmented(
158+ listOf(SegmentOption(false, "Public", VIcons.Globe), SegmentOption(true, "Private", VIcons.Lock)),
159+ selected = newPrivate,
160+ onSelect = { newPrivate = it },
161+ )
162+ VButton(
163+ "Create and Save",
164+ {
165+ creating = true
166+ scope.launch {
167+ try {
168+ val list = api.createList(newOwner, newName.trim(), newPrivate, repo = "$owner/$name")
169+ show(api.saveTargets(owner, name))
170+ newName = ""
171+ toaster.show("Saved to ${list.name}.")
172+ } catch (e: Exception) {
173+ toaster.error(describe(e))
174+ } finally {
175+ creating = false
176+ }
177+ }
178+ },
179+ kind = ButtonKind.Primary,
180+ icon = VIcons.Plus,
181+ enabled = newName.isNotBlank(),
182+ loading = creating,
183+ fill = true,
184+ )
185+ }
186+ }
187+ }
188+ }
189+ }
190+ }
191+
192+ /** A list in the Save sheet: a tick in a well, its name, and a lock if it's private. */
193+ @Composable
194+ private fun SaveTargetRow(list: RepoListInfo, onToggle: () -> Unit) {
195+ val c = Vectrum.colors
196+ PressableRow(onToggle) {
197+ Row(Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically) {
198+ Box(Modifier.size(24.dp).well(RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) {
199+ if (list.saved) Icon(VIcons.Check, "Saved", Modifier.size(17.dp), tint = c.greenDeep)
200+ }
201+ Spacer(Modifier.width(14.dp))
202+ Text(list.name, style = Vectrum.type.lead, color = c.ink, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
203+ if (list.isPrivate) Icon(VIcons.Lock, "Private", Modifier.size(15.dp), tint = c.ink3)
204+ }
205+ }
206+ }
207+
208+ /** A list in a profile's Lists tab. */
209+ @Composable
210+ fun ListRow(list: RepoListInfo, onClick: () -> Unit) {
211+ val c = Vectrum.colors
212+ PressableRow(onClick) {
213+ Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.Top) {
214+ IconTile(VIcons.Bookmark, size = 40.dp)
215+ Spacer(Modifier.width(14.dp))
216+ Column(Modifier.weight(1f)) {
217+ Text(list.name, style = Vectrum.type.title, color = c.ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
218+ if (list.description.isNotBlank()) {
219+ Text(list.description, style = Vectrum.type.body, color = c.ink2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp))
220+ }
221+ Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
222+ if (list.isPrivate) IconText(VIcons.Lock, "Private")
223+ IconText(VIcons.Repo, plural(list.repoCount, "repository", "repositories"))
224+ Text("Updated ${timeAgo(list.updatedAt)}", style = Vectrum.type.meta, color = c.ink3, maxLines = 1)
225+ }
226+ }
227+ }
228+ }
229+ }
230+
231+ /** A list: its repositories, and for those who may change it, removing them, editing and deleting it. */
232+ @OptIn(ExperimentalLayoutApi::class)
233+ @Composable
234+ fun ListScreen(owner: String, slug: String) {
235+ val api = LocalApi.current!!
236+ val nav = LocalNav.current
237+ val toaster = LocalToaster.current
238+ val scope = rememberCoroutineScope()
239+ val loader = rememberLoader("list:$owner/$slug") { api.list(owner, slug) }
240+ val list = rememberLazyListState()
241+ var editing by rememberSaveable { mutableStateOf(false) }
242+ val c = Vectrum.colors
243+ RefreshOnReturn { loader.load(refresh = true) }
244+
245+ Column(Modifier.fillMaxSize()) {
246+ TopBar(
247+ loader.data?.list?.name ?: "List",
248+ kicker = owner,
249+ onBack = nav::back,
250+ scrolled = list.isScrolled(),
251+ titleVisible = list.firstVisibleItemIndex > 0,
252+ actions = { if (loader.data?.canManage == true) RoundButton(VIcons.Pen, "Edit list", { editing = true }) },
253+ )
254+ Loaded(loader, state = list) { detail ->
255+ val info = detail.list
256+ item("header") {
257+ Column(Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 22.dp)) {
258+ Row(verticalAlignment = Alignment.CenterVertically) {
259+ IconTile(VIcons.Bookmark, size = 56.dp)
260+ Spacer(Modifier.width(16.dp))
261+ Column(Modifier.weight(1f)) {
262+ Text(info.owner, style = Vectrum.type.ui, color = c.ink3, maxLines = 1, modifier = Modifier.padding(bottom = 2.dp))
263+ Text(info.name, style = Vectrum.type.display.copy(fontSize = Vectrum.type.stat.fontSize), color = c.ink, maxLines = 2, overflow = TextOverflow.Ellipsis)
264+ }
265+ }
266+ if (info.description.isNotBlank()) {
267+ Spacer(Modifier.height(14.dp))
268+ Text(info.description, style = Vectrum.type.lead, color = c.ink2)
269+ }
270+ Spacer(Modifier.height(14.dp))
271+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), itemVerticalAlignment = Alignment.CenterVertically) {
272+ Pill(if (info.isPrivate) "Private" else "Public", tone = if (info.isPrivate) Tone.Attention else Tone.Default)
273+ if (info.ownerIsOrg) Pill("Organization", icon = VIcons.People)
274+ }
275+ }
276+ }
277+ item("repos") {
278+ Section("Repositories", count = detail.repos.size) {
279+ Plate(Modifier.fillMaxWidth()) {
280+ if (detail.repos.isEmpty()) {
281+ EmptyState(
282+ VIcons.Bookmark,
283+ "This list is empty",
284+ text = if (detail.canManage) "Open a repository and use its Save key to put it here." else "Nothing has been saved to it yet.",
285+ )
286+ } else {
287+ PlateRows(detail.repos) { repo ->
288+ RepoRow(
289+ repo,
290+ { nav.repo(repo.owner, repo.name) },
291+ trailing = if (detail.canManage) {
292+ {
293+ RoundButton(VIcons.Close, "Remove ${repo.name} from the list", {
294+ scope.launch {
295+ try {
296+ api.setSaved(repo.owner, repo.name, info.id, false)
297+ loader.set(detail.copy(repos = detail.repos.filter { it.id != repo.id }, list = info.copy(repoCount = info.repoCount - 1)))
298+ toaster.show("Removed ${repo.name}.")
299+ } catch (e: Exception) {
300+ toaster.error(describe(e))
301+ }
302+ }
303+ }, size = 34.dp, ghost = true, tint = c.ink3)
304+ }
305+ } else {
306+ null
307+ },
308+ )
309+ }
310+ }
311+ }
312+ }
313+ }
314+ }
315+ }
316+
317+ val current = loader.data?.list
318+ if (editing && current != null) {
319+ EditListSheet(
320+ current,
321+ onDismiss = { editing = false },
322+ onSaved = { updated ->
323+ editing = false
324+ if (updated.slug != slug) {
325+ // Renaming moved it to a new address.
326+ nav.back()
327+ nav.list(updated.owner, updated.slug)
328+ } else {
329+ loader.load(refresh = true)
330+ }
331+ },
332+ onDeleted = {
333+ editing = false
334+ nav.back()
335+ },
336+ )
337+ }
338+ }
339+
340+ /** A new list for [owner] (you, or an organization you can push in), from a profile's Lists tab. */
341+ @Composable
342+ fun NewListSheet(owner: String, onDismiss: () -> Unit, onCreated: (RepoListInfo) -> Unit) {
343+ val api = LocalApi.current!!
344+ val toaster = LocalToaster.current
345+ val scope = rememberCoroutineScope()
346+ ListFieldsSheet("New list", initial = null, onDismiss = onDismiss) { name, description, private ->
347+ scope.launch {
348+ try {
349+ val list = api.createList(owner, name, private, description)
350+ toaster.show("Created ${list.name}.")
351+ onCreated(list)
352+ } catch (e: Exception) {
353+ toaster.error(describe(e))
354+ }
355+ }
356+ }
357+ }
358+
359+ @Composable
360+ private fun EditListSheet(list: RepoListInfo, onDismiss: () -> Unit, onSaved: (RepoListInfo) -> Unit, onDeleted: () -> Unit) {
361+ val api = LocalApi.current!!
362+ val toaster = LocalToaster.current
363+ val scope = rememberCoroutineScope()
364+ var confirming by rememberSaveable { mutableStateOf(false) }
365+ var deleting by remember { mutableStateOf(false) }
366+ ListFieldsSheet(
367+ "Edit list",
368+ initial = list,
369+ onDismiss = onDismiss,
370+ footer = {
371+ Spacer(Modifier.height(4.dp))
372+ if (confirming) {
373+ Text("The list goes for good; the repositories in it stay as they are.", style = Vectrum.type.body, color = Vectrum.colors.red)
374+ VButton(
375+ "Delete ${list.name}",
376+ {
377+ deleting = true
378+ scope.launch {
379+ try {
380+ api.deleteList(list.owner, list.slug)
381+ toaster.show("Deleted ${list.name}.")
382+ onDeleted()
383+ } catch (e: Exception) {
384+ toaster.error(describe(e))
385+ } finally {
386+ deleting = false
387+ }
388+ }
389+ },
390+ kind = ButtonKind.Danger,
391+ icon = VIcons.Close,
392+ loading = deleting,
393+ fill = true,
394+ )
395+ } else {
396+ VButton("Delete List…", { confirming = true }, kind = ButtonKind.Ghost, icon = VIcons.Close, fill = true)
397+ }
398+ },
399+ ) { name, description, private ->
400+ scope.launch {
401+ try {
402+ val updated = api.updateList(list.owner, list.slug, name, description, private)
403+ toaster.show("Saved ${updated.name}.")
404+ onSaved(updated)
405+ } catch (e: Exception) {
406+ toaster.error(describe(e))
407+ }
408+ }
409+ }
410+ }
411+
412+ /** A list's name, description and visibility in a sheet. */
413+ @Composable
414+ private fun ListFieldsSheet(
415+ title: String,
416+ initial: RepoListInfo?,
417+ onDismiss: () -> Unit,
418+ footer: @Composable () -> Unit = {},
419+ onSubmit: (name: String, description: String, private: Boolean) -> Unit,
420+ ) {
421+ var name by rememberSaveable { mutableStateOf(initial?.name ?: "") }
422+ var description by rememberSaveable { mutableStateOf(initial?.description ?: "") }
423+ var private by rememberSaveable { mutableStateOf(initial?.isPrivate ?: false) }
424+ VSheet(title, onDismiss) {
425+ Column(Modifier.padding(horizontal = 20.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp)) {
426+ Field(name, { name = it.take(60) }, label = "Name", placeholder = "Tools I use")
427+ Field(description, { description = it.take(300) }, label = "Description", placeholder = "What do these repositories have in common?", singleLine = false, minLines = 2)
428+ Segmented(
429+ listOf(SegmentOption(false, "Public", VIcons.Globe), SegmentOption(true, "Private", VIcons.Lock)),
430+ selected = private,
431+ onSelect = { private = it },
432+ )
433+ Text(
434+ if (private) "Only you can see it, or for an organization’s list, its members." else "Anyone can see it. Private repositories in it stay hidden from those who can’t see them.",
435+ style = Vectrum.type.meta,
436+ color = Vectrum.colors.ink3,
437+ )
438+ VButton(
439+ if (initial == null) "Create List" else "Save List",
440+ { onSubmit(name.trim(), description.trim(), private) },
441+ kind = ButtonKind.Primary,
442+ icon = if (initial == null) VIcons.Plus else VIcons.Check,
443+ enabled = name.isNotBlank(),
444+ fill = true,
445+ )
446+ footer()
447+ Spacer(Modifier.height(4.dp))
448+ }
449+ }
450+ }
+76−11apps/mobile/app/src/main/java/cc/vectrum/app/ui/screens/ProfileScreens.ktView File
@@ -20,6 +20,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState
2020 import androidx.compose.material3.Icon
2121 import androidx.compose.material3.Text
2222 import androidx.compose.runtime.Composable
23+ import androidx.compose.runtime.getValue
24+ import androidx.compose.runtime.mutableStateOf
25+ import androidx.compose.runtime.saveable.rememberSaveable
26+ import androidx.compose.runtime.setValue
2327 import androidx.compose.ui.Alignment
2428 import androidx.compose.ui.Modifier
2529 import androidx.compose.ui.text.style.TextAlign
@@ -29,6 +33,7 @@ import cc.vectrum.app.data.ProfileData
2933 import cc.vectrum.app.ui.LocalApi
3034 import cc.vectrum.app.ui.LocalNav
3135 import cc.vectrum.app.ui.LocalSession
36+ import cc.vectrum.app.ui.RefreshOnReturn
3237 import cc.vectrum.app.ui.components.Avatar
3338 import cc.vectrum.app.ui.components.BottomClearance
3439 import cc.vectrum.app.ui.components.ButtonKind
@@ -50,8 +55,14 @@ import cc.vectrum.app.ui.rememberLoader
5055 import cc.vectrum.app.ui.theme.ThemeMode
5156 import cc.vectrum.app.ui.theme.Vectrum
5257
53- /** A person's card and their repositories, for their profile and the "You" tab. */
54- private fun LazyListScope.profileBody(data: ProfileData, onRepo: (String, String) -> Unit) {
58+ /** What a profile shows below its card, like the website's tabs. Organizations don't like repositories. */
59+ enum class ProfileTab(val label: String) { Repositories("Repositories"), Lists("Lists"), Liked("Liked") }
60+
61+ /** What a profile's rows open. */
62+ class ProfileActions(val repo: (String, String) -> Unit, val list: (String, String) -> Unit, val newList: () -> Unit)
63+
64+ /** A person's or organization's card, then their repositories, lists or liked repositories, for their profile and the "You" tab. */
65+ private fun LazyListScope.profileBody(data: ProfileData, tab: ProfileTab, onTab: (ProfileTab) -> Unit, actions: ProfileActions) {
5566 item("card") {
5667 val c = Vectrum.colors
5768 Column(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
@@ -64,17 +75,52 @@ private fun LazyListScope.profileBody(data: ProfileData, onRepo: (String, String
6475 Text(data.user.bio, style = Vectrum.type.body, color = c.ink2, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 24.dp))
6576 }
6677 Spacer(Modifier.height(10.dp))
67- if (data.user.createdAt > 0) IconText(VIcons.Calendar, "Joined ${formatDate(data.user.createdAt)}")
68- Spacer(Modifier.height(26.dp))
78+ if (data.user.createdAt > 0) IconText(VIcons.Calendar, "${if (data.isOrg) "Created" else "Joined"} ${formatDate(data.user.createdAt)}")
79+ Spacer(Modifier.height(22.dp))
6980 }
7081 }
71- item("repos") {
72- Section("Repositories", count = data.repos.size) {
73- Plate(Modifier.fillMaxWidth()) {
82+ item("tabs") {
83+ Segmented(
84+ ProfileTab.entries.filter { !(data.isOrg && it == ProfileTab.Liked) }.map {
85+ SegmentOption(
86+ it,
87+ it.label,
88+ count = when (it) {
89+ ProfileTab.Repositories -> data.repos.size
90+ ProfileTab.Lists -> data.lists.size
91+ ProfileTab.Liked -> data.liked.size
92+ },
93+ )
94+ },
95+ selected = tab,
96+ onSelect = onTab,
97+ )
98+ Spacer(Modifier.height(18.dp))
99+ }
100+ item("content") {
101+ when (tab) {
102+ ProfileTab.Repositories -> Plate(Modifier.fillMaxWidth()) {
74103 if (data.repos.isEmpty()) {
75104 EmptyState(VIcons.Repo, "No repositories to show")
76105 } else {
77- PlateRows(data.repos) { repo -> RepoRow(repo, { onRepo(repo.owner, repo.name) }, showOwner = false) }
106+ PlateRows(data.repos) { repo -> RepoRow(repo, { actions.repo(repo.owner, repo.name) }, showOwner = false) }
107+ }
108+ }
109+ ProfileTab.Lists -> Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
110+ Plate(Modifier.fillMaxWidth()) {
111+ if (data.lists.isEmpty()) {
112+ EmptyState(VIcons.Bookmark, "No lists yet", text = if (data.canManageLists) "Save repositories to lists to find them again or share them." else null)
113+ } else {
114+ PlateRows(data.lists) { list -> ListRow(list) { actions.list(list.owner, list.slug) } }
115+ }
116+ }
117+ if (data.canManageLists) VButton("New List", actions.newList, icon = VIcons.Plus, fill = true)
118+ }
119+ ProfileTab.Liked -> Plate(Modifier.fillMaxWidth()) {
120+ if (data.liked.isEmpty()) {
121+ EmptyState(VIcons.Heart, "No liked repositories yet")
122+ } else {
123+ PlateRows(data.liked) { repo -> RepoRow(repo, { actions.repo(repo.owner, repo.name) }) }
78124 }
79125 }
80126 }
@@ -82,15 +128,33 @@ private fun LazyListScope.profileBody(data: ProfileData, onRepo: (String, String
82128 }
83129 }
84130
131+ /** Opens a profile's lists and repositories, and makes new lists for it. */
132+ @Composable
133+ private fun rememberProfileTabs(username: String, onCreated: () -> Unit): Triple<ProfileTab, (ProfileTab) -> Unit, ProfileActions> {
134+ val nav = LocalNav.current
135+ var tab by rememberSaveable { mutableStateOf(ProfileTab.Repositories) }
136+ var creating by rememberSaveable { mutableStateOf(false) }
137+ if (creating) {
138+ NewListSheet(username, onDismiss = { creating = false }) { list ->
139+ creating = false
140+ onCreated()
141+ nav.list(list.owner, list.slug)
142+ }
143+ }
144+ return Triple(tab, { t: ProfileTab -> tab = t }, ProfileActions(repo = { o, n -> nav.repo(o, n) }, list = { o, s -> nav.list(o, s) }, newList = { creating = true }))
145+ }
146+
85147 @Composable
86148 fun ProfileScreen(username: String) {
87149 val api = LocalApi.current!!
88150 val nav = LocalNav.current
89151 val loader = rememberLoader("profile:$username") { api.profile(username) }
90152 val list = rememberLazyListState()
153+ val (tab, onTab, actions) = rememberProfileTabs(username) { loader.load(refresh = true) }
154+ RefreshOnReturn { loader.load(refresh = true) }
91155 Column(Modifier.fillMaxSize()) {
92156 TopBar("@$username", kicker = "Profile", onBack = nav::back, scrolled = list.isScrolled())
93- Loaded(loader, state = list) { data -> profileBody(data) { o, n -> nav.repo(o, n) } }
157+ Loaded(loader, state = list) { data -> profileBody(data, tab, onTab, actions) }
94158 }
95159 }
96160
@@ -98,14 +162,15 @@ fun ProfileScreen(username: String) {
98162 @Composable
99163 fun MeScreen(theme: ThemeMode, onTheme: (ThemeMode) -> Unit, onSignOut: () -> Unit) {
100164 val api = LocalApi.current!!
101- val nav = LocalNav.current
102165 val session = LocalSession.current!!
103166 val loader = rememberLoader("me") { api.profile(session.user.username) }
167+ val (tab, onTab, actions) = rememberProfileTabs(session.user.username) { loader.load(refresh = true) }
168+ RefreshOnReturn { loader.load(refresh = true) }
104169 val top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
105170 val c = Vectrum.colors
106171
107172 Loaded(loader, contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = top + 20.dp, bottom = BottomClearance)) { data ->
108- profileBody(data) { o, n -> nav.repo(o, n) }
173+ profileBody(data, tab, onTab, actions)
109174 item("appearance") {
110175 Section("Appearance") {
111176 Segmented(
+33−2apps/mobile/app/src/main/java/cc/vectrum/app/ui/screens/RepoScreen.ktView File
@@ -74,6 +74,7 @@ import cc.vectrum.app.ui.components.Tone
7474 import cc.vectrum.app.ui.components.TopBar
7575 import cc.vectrum.app.ui.components.VButton
7676 import cc.vectrum.app.ui.components.isScrolled
77+ import cc.vectrum.app.ui.describe
7778 import cc.vectrum.app.ui.formatBytes
7879 import cc.vectrum.app.ui.icons.VIcons
7980 import cc.vectrum.app.ui.plural
@@ -119,6 +120,9 @@ fun RepoScreen(owner: String, name: String, initialTab: String) {
119120 var tab by rememberSaveable { mutableStateOf(RepoTab.entries.firstOrNull { it.key == initialTab } ?: RepoTab.Code) }
120121 var ref by rememberSaveable { mutableStateOf<String?>(null) }
121122 var showBranches by rememberSaveable { mutableStateOf(false) }
123+ var showSave by rememberSaveable { mutableStateOf(false) }
124+ val toaster = LocalToaster.current
125+ val scope = rememberCoroutineScope()
122126 val list = rememberLazyListState()
123127 // Each tab loads when it's first opened, and keeps its data (in its view model) while you switch around.
124128 val issues = if (tab == RepoTab.Issues) rememberIssuesTab(owner, name) else null
@@ -155,7 +159,27 @@ fun RepoScreen(owner: String, name: String, initialTab: String) {
155159 val currentRef = ref ?: detail.defaultBranch
156160 PullToRefreshBox(isRefreshing = repoLoader.refreshing, onRefresh = { repoLoader.load(true) }, modifier = Modifier.fillMaxSize()) {
157161 LazyColumn(Modifier.fillMaxSize(), state = list, contentPadding = PaddingValues(bottom = BottomClearance)) {
158- item("header") { RepoHeader(detail) }
162+ item("header") {
163+ RepoHeader(
164+ detail,
165+ onLike = {
166+ val liked = !detail.liked
167+ val before = detail
168+ // Shows at once; the server's count replaces it.
169+ repoLoader.set(detail.copy(liked = liked, repo = detail.repo.copy(likes = (detail.repo.likes + if (liked) 1 else -1).coerceAtLeast(0))))
170+ scope.launch {
171+ try {
172+ val r = api.like(owner, name, liked)
173+ repoLoader.data?.let { repoLoader.set(it.copy(liked = r.liked, repo = it.repo.copy(likes = r.likes))) }
174+ } catch (e: Exception) {
175+ repoLoader.set(before)
176+ toaster.error(describe(e))
177+ }
178+ }
179+ },
180+ onSave = { showSave = true },
181+ )
182+ }
159183 stickyHeader("tabs") {
160184 // The page fades out under the tabs, so rows slide away beneath them.
161185 val page = Vectrum.colors.page
@@ -188,6 +212,11 @@ fun RepoScreen(owner: String, name: String, initialTab: String) {
188212 }
189213 }
190214 }
215+ if (showSave) {
216+ SaveSheet(owner, name, onDismiss = { showSave = false }, onChanged = { saved ->
217+ repoLoader.data?.let { if (it.saved != saved) repoLoader.set(it.copy(saved = saved)) }
218+ })
219+ }
191220 if (showBranches) {
192221 BranchSheet(detail, currentRef, onPick = { ref = it; showBranches = false }, onDismiss = { showBranches = false })
193222 }
@@ -198,7 +227,7 @@ fun RepoScreen(owner: String, name: String, initialTab: String) {
198227
199228 @OptIn(ExperimentalLayoutApi::class)
200229 @Composable
201- private fun RepoHeader(detail: RepoDetail) {
230+ private fun RepoHeader(detail: RepoDetail, onLike: () -> Unit, onSave: () -> Unit) {
202231 val c = Vectrum.colors
203232 val nav = LocalNav.current
204233 val repo = detail.repo
@@ -234,6 +263,8 @@ private fun RepoHeader(detail: RepoDetail) {
234263 }
235264 }
236265 Spacer(Modifier.height(14.dp))
266+ LikeSaveRow(detail.liked, repo.likes, detail.saved, onLike, onSave)
267+ Spacer(Modifier.height(14.dp))
237268 Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
238269 IconText(VIcons.History, plural(detail.counts.commits, "commit"))
239270 IconText(VIcons.Branch, plural(detail.counts.branches, "branch", "branches"))
+6−1apps/mobile/app/src/main/java/cc/vectrum/app/ui/screens/Rows.ktView File
@@ -60,7 +60,7 @@ fun <T> PlateRows(items: List<T>, row: @Composable (T) -> Unit) {
6060 }
6161
6262 @Composable
63- fun RepoRow(repo: RepoSummary, onClick: () -> Unit, showOwner: Boolean = true) {
63+ fun RepoRow(repo: RepoSummary, onClick: () -> Unit, showOwner: Boolean = true, trailing: (@Composable () -> Unit)? = null) {
6464 val c = Vectrum.colors
6565 PressableRow(onClick) {
6666 Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.Top) {
@@ -83,9 +83,14 @@ fun RepoRow(repo: RepoSummary, onClick: () -> Unit, showOwner: Boolean = true) {
8383 Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
8484 if (repo.isPrivate) IconText(VIcons.Lock, "Private")
8585 if (repo.isFork) IconText(VIcons.Fork, repo.forkOf?.let { "Fork of ${it.owner}/${it.name}" } ?: "Fork")
86+ if (repo.likes > 0) IconText(VIcons.Heart, "${repo.likes}")
8687 Text(if (repo.empty) "Empty" else "Updated ${timeAgo(repo.updatedAt)}", style = Vectrum.type.meta, color = c.ink3, maxLines = 1)
8788 }
8889 }
90+ if (trailing != null) {
91+ Spacer(Modifier.width(8.dp))
92+ trailing()
93+ }
8994 }
9095 }
9196 }
+17−0apps/web/app/api/v1/[...path]/route.tsView File
@@ -13,6 +13,10 @@ import { ApiError, apiViewer, errorResponse, json } from "@/lib/app-api";
1313 // GET, PATCH …/issues/<n>; POST …/issues, …/issues/<n>/comments
1414 // GET, PATCH …/pulls/<n>; GET …/pulls/<n>/files; POST …/pulls/<n>/comments, /reviews, /merge
1515 // GET …/runs/<n>
16+ // POST …/like { liked } → { liked, likes }
17+ // GET, POST …/lists the lists the repository can be saved to; POST { list, saved }
18+ // POST /api/v1/lists a new list: { owner?, name, description?, visibility?, repo? }
19+ // GET, PATCH, DELETE /api/v1/lists/<owner>/<slug>
1620
1721 type Ctx = RouteContext<"/api/v1/[...path]">;
1822
@@ -42,6 +46,12 @@ async function handle(request: Request, ctx: Ctx) {
4246 if (first === "explore") return json(await api.explore(viewer, params.get("q") ?? ""));
4347 }
4448 if (first === "users" && rest.length === 1 && method === "GET") return json(await api.profile(viewer, rest[0]));
49+ if (first === "lists") {
50+ if (rest.length === 0 && method === "POST") return json(await api.newList(request, viewer), 201);
51+ if (rest.length === 2 && method === "GET") return json(await api.listPage(viewer, rest[0], rest[1]));
52+ if (rest.length === 2 && method === "PATCH") return json(await api.changeList(request, viewer, rest[0], rest[1]));
53+ if (rest.length === 2 && method === "DELETE") return json(await api.removeList(viewer, rest[0], rest[1]));
54+ }
4555
4656 if (first === "repos" && rest.length >= 2) {
4757 const [owner, name, section, id, sub, ...more] = rest;
@@ -96,6 +106,12 @@ async function handle(request: Request, ctx: Ctx) {
96106 return json(await api.run(viewer, owner, name, n()));
97107 case "GET releases":
98108 return json(await api.releases(viewer, owner, name));
109+ case "POST like":
110+ return json(await api.likeRepo(request, viewer, owner, name));
111+ case "GET lists":
112+ return json(await api.repoLists(viewer, owner, name));
113+ case "POST lists":
114+ return json(await api.saveRepo(request, viewer, owner, name));
99115 }
100116 }
101117 throw new ApiError(404, "Not found.");
@@ -107,3 +123,4 @@ async function handle(request: Request, ctx: Ctx) {
107123 export const GET = handle;
108124 export const POST = handle;
109125 export const PATCH = handle;
126+ export const DELETE = handle;
+154−6apps/web/lib/app-api.tsView File
@@ -22,6 +22,26 @@ import {
2222 validateIssuePatch,
2323 } from "./issues";
2424 import { recentActivity, withActivity } from "./listing";
25+ import {
26+ countLikes,
27+ createList,
28+ deleteList,
29+ findList,
30+ findListById,
31+ likes,
32+ listLikedRepos,
33+ listListsOf,
34+ listReposInList,
35+ ListError,
36+ mayManageLists,
37+ maySeeList,
38+ saveTargets,
39+ setInList,
40+ setLiked,
41+ updateList,
42+ withRepoCounts,
43+ type RepoList,
44+ } from "./lists";
2545 import { IMAGE_TYPES } from "./mime";
2646 import { ProtectionError } from "./protection";
2747 import {
@@ -73,7 +93,7 @@ export class ApiError extends Error {
7393 }
7494
7595 const isUserError = (e: unknown) =>
76- e instanceof IssueError || e instanceof LabelError || e instanceof PullError || e instanceof ProtectionError || e instanceof GitError;
96+ e instanceof IssueError || e instanceof LabelError || e instanceof PullError || e instanceof ProtectionError || e instanceof GitError || e instanceof ListError;
7797
7898 /** Turns what a handler threw into a JSON answer: user errors become 4xx, anything else is rethrown. */
7999 export function errorResponse(e: unknown) {
@@ -180,7 +200,7 @@ export async function signIn(request: Request) {
180200
181201 const userJson = (u: User) => ({ id: u.id, username: u.username, name: u.name, email: u.email, bio: u.bio, createdAt: u.createdAt });
182202
183- type Listed = RepoRecord & { updatedAt?: number; empty?: boolean; parent?: RepoRecord["forkOf"] };
203+ type Listed = RepoRecord & { updatedAt?: number; empty?: boolean; parent?: RepoRecord["forkOf"]; likes?: number };
184204
185205 function repoJson(r: Listed) {
186206 return {
@@ -197,6 +217,7 @@ function repoJson(r: Listed) {
197217 createdAt: r.createdAt,
198218 updatedAt: r.updatedAt ?? r.pushedAt ?? r.createdAt,
199219 empty: r.empty ?? false,
220+ likes: r.likes ?? 0,
200221 };
201222 }
202223
@@ -329,11 +350,24 @@ export async function explore(viewer: User | null, query: string) {
329350 return { repos: repos.map(repoJson) };
330351 }
331352
353+ /** A person's or an organization's page: their repositories and lists, and for people the repositories they like. */
332354 export async function profile(viewer: User | null, username: string) {
333355 const user = await getUserByUsername(username);
334356 if (!user) throw new ApiError(404, "There's no such user.");
335- const repos = await withActivity(await listReposByOwner(user.id, viewer?.id ?? null), viewer?.id);
336- return { user: { ...userJson(user), email: viewer?.id === user.id ? user.email : "" }, repos: repos.map(repoJson) };
357+ const viewerId = viewer?.id ?? null;
358+ const [repos, lists, liked] = await Promise.all([
359+ listReposByOwner(user.id, viewerId).then((r) => withActivity(r, viewerId)),
360+ listListsOf(user, viewerId).then((l) => withRepoCounts(l, viewerId)),
361+ user.isOrg ? [] : listLikedRepos(user.id, viewerId).then((r) => withActivity(r, viewerId)),
362+ ]);
363+ return {
364+ user: { ...userJson(user), email: viewer?.id === user.id ? user.email : "" },
365+ isOrg: user.isOrg,
366+ repos: repos.map(repoJson),
367+ lists: lists.map(listJson),
368+ liked: liked.map(repoJson),
369+ canManageLists: await mayManageLists(user, viewerId),
370+ };
337371 }
338372
339373 // ---------------------------------------------------------------------------
@@ -379,7 +413,7 @@ const README = /^readme(\.md|\.markdown|\.txt)?$/i;
379413 export async function repository(viewer: User | null, owner: string, name: string) {
380414 const { repo, access } = await openRepo(viewer, owner, name);
381415 const branch = await defaultBranch(repo.dir);
382- const [parent, urls, branches, tags, issues, pulls, releases] = await Promise.all([
416+ const [parent, urls, branches, tags, issues, pulls, releases, likeCount, liked] = await Promise.all([
383417 visibleParent(repo, viewer?.id),
384418 cloneUrls(repo),
385419 listBranches(repo.dir),
@@ -387,7 +421,11 @@ export async function repository(viewer: User | null, owner: string, name: strin
387421 openIssueCount(repo.id),
388422 countOpenPulls(repo.id),
389423 countReleases(repo.id),
424+ countLikes(repo.id),
425+ likes(repo.id, viewer?.id),
390426 ]);
427+ // Whether it's in any of the lists the viewer keeps (theirs and their organizations').
428+ const saved = viewer ? (await saveTargets(viewer, repo.id)).lists.some((l) => l.saved) : false;
391429 let readme: { path: string; text: string } | null = null;
392430 let entries: Awaited<ReturnType<typeof listTree>> = [];
393431 let lastCommit: Commit | null = null;
@@ -402,8 +440,10 @@ export async function repository(viewer: User | null, owner: string, name: strin
402440 }
403441 }
404442 return {
405- repo: repoJson({ ...repo, parent, updatedAt: lastCommit?.date ?? repo.pushedAt ?? repo.createdAt, empty: !branch }),
443+ repo: repoJson({ ...repo, parent, updatedAt: lastCommit?.date ?? repo.pushedAt ?? repo.createdAt, empty: !branch, likes: likeCount }),
406444 access,
445+ liked,
446+ saved,
407447 canWrite: canWrite(access),
408448 defaultBranch: branch,
409449 branches: branches.map((b) => b.name),
@@ -782,3 +822,111 @@ export async function releases(viewer: User | null, owner: string, name: string)
782822 };
783823 }
784824
825+
826+ // ---------------------------------------------------------------------------
827+ // Likes and lists (lib/lists.ts)
828+
829+ const listJson = (l: RepoList & { repoCount?: number }) => ({
830+ id: l.id,
831+ owner: l.owner.username,
832+ ownerIsOrg: l.owner.isOrg,
833+ name: l.name,
834+ slug: l.slug,
835+ description: l.description,
836+ visibility: l.visibility,
837+ createdAt: l.createdAt,
838+ updatedAt: l.updatedAt,
839+ repoCount: l.repoCount ?? 0,
840+ });
841+
842+ /** Likes or unlikes a repository: `{ liked }`. */
843+ export async function likeRepo(request: Request, viewer: User | null, owner: string, name: string) {
844+ const user = requireUser(viewer);
845+ const { repo } = await openRepo(viewer, owner, name);
846+ const liked = (await readJson(request)).liked === true;
847+ await setLiked(repo, user, liked);
848+ return { liked, likes: await countLikes(repo.id) };
849+ }
850+
851+ /** The lists the person may save the repository to, and whether it's in each: for the app's Save sheet. */
852+ export async function repoLists(viewer: User | null, owner: string, name: string) {
853+ const user = requireUser(viewer);
854+ const { repo } = await openRepo(viewer, owner, name);
855+ const targets = await saveTargets(user, repo.id);
856+ return { owners: targets.owners.map((o) => o.username), lists: targets.lists.map((l) => ({ ...listJson(l), saved: l.saved })) };
857+ }
858+
859+ /** A list the person may change, by id. */
860+ async function managedList(user: User, id: string) {
861+ const list = await findListById(id);
862+ if (!list || !(await mayManageLists(list.owner, user.id))) throw new ApiError(404, "There's no such list, or you can't change it.");
863+ return list;
864+ }
865+
866+ /** Puts the repository in a list or takes it out: `{ list, saved }`. Answers like repoLists. */
867+ export async function saveRepo(request: Request, viewer: User | null, owner: string, name: string) {
868+ const user = requireUser(viewer);
869+ const { repo } = await openRepo(viewer, owner, name);
870+ const body = await readJson(request);
871+ const list = await managedList(user, text(body.list, 40));
872+ await setInList(list, repo.id, body.saved === true);
873+ return repoLists(viewer, owner, name);
874+ }
875+
876+ const listInput = (body: Record<string, unknown>) => ({
877+ name: text(body.name, 200),
878+ description: text(body.description, 1000),
879+ visibility: body.visibility === "private" ? ("private" as const) : ("public" as const),
880+ });
881+
882+ /** Makes a list for the person or one of their organizations: `{ owner?, name, description?, visibility?, repo? }`, `repo` as "owner/name" to put in it. */
883+ export async function newList(request: Request, viewer: User | null) {
884+ const user = requireUser(viewer);
885+ const body = await readJson(request);
886+ const owner = body.owner ? await getUserByUsername(text(body.owner, 100)) : user;
887+ if (!owner || !(await mayManageLists(owner, user.id))) throw new ApiError(403, "You can't make lists for that account.");
888+ const [repoOwner, repoName] = text(body.repo, 300).split("/");
889+ const repo = repoOwner && repoName ? (await openRepo(viewer, repoOwner, repoName)).repo : null;
890+ const list = await createList(owner, listInput(body));
891+ if (repo) await setInList(list, repo.id, true);
892+ return { list: listJson({ ...list, repoCount: repo ? 1 : 0 }) };
893+ }
894+
895+ async function visibleList(viewer: User | null, owner: string, slug: string) {
896+ const list = await findList(owner, slug);
897+ // Lists the viewer can't see are "not found", like private repositories.
898+ if (!list || !(await maySeeList(list, viewer?.id))) throw new ApiError(404, "There's no such list, or you can't see it.");
899+ return list;
900+ }
901+
902+ /** A list and the repositories in it the viewer may see. */
903+ export async function listPage(viewer: User | null, owner: string, slug: string) {
904+ const found = await visibleList(viewer, owner, slug);
905+ const repos = await withActivity(await listReposInList(found.id, viewer?.id ?? null), viewer?.id);
906+ return { list: listJson({ ...found, repoCount: repos.length }), repos: repos.map(repoJson), canManage: await mayManageLists(found.owner, viewer?.id) };
907+ }
908+
909+ /** A list the viewer may change, by its address. */
910+ async function changeableList(viewer: User | null, owner: string, slug: string) {
911+ const user = requireUser(viewer);
912+ const found = await visibleList(viewer, owner, slug);
913+ if (!(await mayManageLists(found.owner, user.id))) throw new ApiError(403, "You can't change this list.");
914+ return found;
915+ }
916+
917+ /** Renames a list or changes its description or visibility: `{ name?, description?, visibility? }`. Its address follows the name. */
918+ export async function changeList(request: Request, viewer: User | null, owner: string, slug: string) {
919+ const found = await changeableList(viewer, owner, slug);
920+ const body = await readJson(request);
921+ const updated = await updateList(found, {
922+ name: typeof body.name === "string" ? text(body.name, 200) : found.name,
923+ description: typeof body.description === "string" ? text(body.description, 1000) : found.description,
924+ visibility: body.visibility === "public" || body.visibility === "private" ? body.visibility : found.visibility,
925+ });
926+ return { list: listJson(updated) };
927+ }
928+
929+ export async function removeList(viewer: User | null, owner: string, slug: string) {
930+ await deleteList(await changeableList(viewer, owner, slug));
931+ return {};
932+ }
+1−1docs/features.mdView File
@@ -5,7 +5,7 @@
55 - **Repositories**: public or private, rename, delete, optional README on creation
66 - **Collaborators**: give other users push access to a repository
77 - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`)
8- - **Likes and lists**: anyone signed in can *Like* a repository they can see; the repository shows how many people like it (and who, under *likes*), and a person's profile lists what they like under *Liked*. The *Save* key puts a repository in lists, or makes a new one on the spot. Lists belong to a person or an organization, live at `/lists/<owner>/<list>` and on the profile's *Lists* tab, and are public (anyone can see them) or private (only that person, or the organization's members). Members with *Write* or more manage an organization's lists. A list never shows a repository to someone who couldn't see it anyway, so private repositories can go in public lists
8+ - **Likes and lists**: anyone signed in can *Like* a repository they can see; the repository shows how many people like it (and who, under *likes*), and a person's profile lists what they like under *Liked*. The *Save* key puts a repository in lists, or makes a new one on the spot. Lists belong to a person or an organization, live at `/lists/<owner>/<list>` and on the profile's *Lists* tab, and are public (anyone can see them) or private (only that person, or the organization's members). Members with *Write* or more manage an organization's lists. A list never shows a repository to someone who couldn't see it anyway, so private repositories can go in public lists. The Android app does all of this too
99 - **Forks**: anyone who can read a repository can fork it (the *Fork* button) into a copy of their own, of its default branch or all branches. Forks carry a *Fork* label and a "forked from" link everywhere they're listed, and the original lists them under *Forks*. A fork's front page says how far it is ahead of and behind the original, with *Sync Fork* (a fast-forward, or a merge commit if the fork has commits of its own) and *Contribute* (a pull request back). Forks of private repositories stay private
1010 - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys*
1111 - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens
+8−0docs/mobile.mdView File
@@ -17,6 +17,10 @@ What it does:
1717 - **Commits**: history by day with CI status, and each commit's diff
1818 - **Actions**: workflow runs, their jobs and steps, and each step's log, live while it runs
1919 - **Releases**: notes and files to download
20+ - **Likes and lists**: like a repository, and save it to your lists or your organizations' (the *Save* key,
21+ which also makes a new list on the spot). Profiles show repositories, lists and liked repositories in tabs;
22+ a list's page lets those who may change it remove repositories, rename it, make it public or private, or
23+ delete it
2024 - Light, dark or following the system, like the website's theme switch
2125
2226 ## Building
@@ -75,6 +79,10 @@ approving, requesting changes and merging need push access and the base branch's
7579 | `GET …/pulls?state=`, `GET, PATCH …/pulls/<n>`, `GET …/pulls/<n>/files` | |
7680 | `POST …/pulls/<n>/comments`, `…/pulls/<n>/reviews`, `…/pulls/<n>/merge` | |
7781 | `GET …/runs?page=`, `…/runs/<n>`, `…/releases` | |
82+ | `POST …/like` | `{ liked }` → `{ liked, likes }` |
83+ | `GET, POST …/lists` | the lists the repository can be saved to, and whether it's in each; `POST { list, saved }` puts it in a list or takes it out |
84+ | `POST /api/v1/lists` | a new list: `{ owner?, name, description?, visibility?, repo? }` (`repo` as `owner/name` goes in it at once) |
85+ | `GET, PATCH, DELETE /api/v1/lists/<owner>/<slug>` | a list and the repositories in it the viewer may see; change its name, description or visibility; delete it |
7886
7987 Errors are `{ "error": "…" }` with a 4xx status; 401 means the session ended.
8088