commit 9494f85b7805c8ea9417800fbd6804c3f97813b9 Author: root Date: Sat Jun 20 03:54:40 2026 -0400 Initial commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f94f243 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + schema-and-codegen: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + - run: | + npm install --no-fund --no-audit + npm run schema:lint + npm run codegen + npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35366fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +build/ +.env +.env.* +!.env.example +*.log +.DS_Store +.idea/ +*.iml +.gradle/ +local.properties +**/app/build/ diff --git a/.graphql-schema-linterrc b/.graphql-schema-linterrc new file mode 100644 index 0000000..756993b --- /dev/null +++ b/.graphql-schema-linterrc @@ -0,0 +1,10 @@ +{ + "rules": [ + "defined-types-are-used", + "deprecations-have-a-reason", + "enum-values-all-caps", + "fields-are-camel-cased", + "input-object-values-are-camel-cased", + "types-are-capitalized" + ] +} diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts new file mode 100644 index 0000000..fe5f214 --- /dev/null +++ b/apps/android/app/build.gradle.kts @@ -0,0 +1,71 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("com.apollographql.apollo3") +} + +android { + namespace = "com.tlm.android" + compileSdk = 34 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "com.tlm.android" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "0.0.1" + val gqlUrl = project.findProperty("TLM_GRAPHQL_URL") as String? ?: "http://10.0.2.2:4000/graphql" + buildConfigField("String", "GRAPHQL_URL", "\"$gqlUrl\"") + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + kotlinOptions { + jvmTarget = "17" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +apollo { + service("tlm") { + packageName.set("com.tlm.graphql") + schemaFile.set(file("../../../packages/graphql-schema/schema.graphql")) + srcDir("src/main/graphql") + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.09.03") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.work:work-runtime-ktx:2.9.1") + + implementation("com.apollographql.apollo3:apollo-runtime:3.8.5") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.google.android.material:material:1.12.0") +} diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2498026 --- /dev/null +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/apps/android/app/src/main/graphql/Chats.graphql b/apps/android/app/src/main/graphql/Chats.graphql new file mode 100644 index 0000000..5f6153e --- /dev/null +++ b/apps/android/app/src/main/graphql/Chats.graphql @@ -0,0 +1,19 @@ +query Chats { + chats { + __typename + id + ... on DirectThread { + peer { + id + displayName + } + } + ... on Group { + title + } + ... on Channel { + title + } + unreadCount + } +} diff --git a/apps/android/app/src/main/graphql/Login.graphql b/apps/android/app/src/main/graphql/Login.graphql new file mode 100644 index 0000000..8cc366b --- /dev/null +++ b/apps/android/app/src/main/graphql/Login.graphql @@ -0,0 +1,10 @@ +mutation Login($email: String!, $password: String!) { + login(email: $email, password: $password) { + accessToken + refreshToken + user { + id + displayName + } + } +} diff --git a/apps/android/app/src/main/graphql/SendMessage.graphql b/apps/android/app/src/main/graphql/SendMessage.graphql new file mode 100644 index 0000000..128dc2c --- /dev/null +++ b/apps/android/app/src/main/graphql/SendMessage.graphql @@ -0,0 +1,7 @@ +mutation SendMessage($chatId: ID!, $body: String) { + sendMessage(chatId: $chatId, body: $body) { + id + seq + body + } +} diff --git a/apps/android/app/src/main/java/com/tlm/android/MainActivity.kt b/apps/android/app/src/main/java/com/tlm/android/MainActivity.kt new file mode 100644 index 0000000..d4cb0ec --- /dev/null +++ b/apps/android/app/src/main/java/com/tlm/android/MainActivity.kt @@ -0,0 +1,193 @@ +package com.tlm.android + +import android.content.Context +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.apollographql.apollo3.ApolloClient +import com.apollographql.apollo3.api.Optional +import com.apollographql.apollo3.exception.ApolloException +import com.tlm.android.data.OutboxRepository +import com.tlm.graphql.ChatsQuery +import com.tlm.graphql.LoginMutation +import com.tlm.graphql.SendMessageMutation +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val prefs = getSharedPreferences("tlm", Context.MODE_PRIVATE) + val ok = + OkHttpClient.Builder() + .addInterceptor { chain -> + val t = prefs.getString("accessToken", null) + val b = chain.request().newBuilder() + if (!t.isNullOrBlank()) b.addHeader("Authorization", "Bearer $t") + chain.proceed(b.build()) + } + .build() + val apollo = + ApolloClient.Builder() + .serverUrl(BuildConfig.GRAPHQL_URL) + .okHttpClient(ok) + .build() + val outbox = OutboxRepository() + + setContent { + MaterialTheme { + val scope = rememberCoroutineScope() + val email = remember { mutableStateOf("alice@example.com") } + val password = remember { mutableStateOf("password123") } + val tokenPresent = remember { mutableStateOf(!prefs.getString("accessToken", null).isNullOrBlank()) } + val chats = remember { mutableStateOf>(emptyList()) } + val activeId = remember { mutableStateOf(null) } + val draft = remember { mutableStateOf("") } + val status = remember { mutableStateOf("") } + + fun loadChats() { + scope.launch { + try { + val res = apollo.query(ChatsQuery()).execute() + if (res.data != null) { + chats.value = res.data!!.chats.filterNotNull() + } else { + status.value = res.errors?.joinToString { it.message } ?: "chats error" + } + } catch (e: ApolloException) { + status.value = e.message ?: "network" + } + } + } + + LaunchedEffect(tokenPresent.value) { + if (tokenPresent.value) loadChats() + } + + Column(Modifier.fillMaxSize().padding(12.dp)) { + Text("TLM (dev)", style = MaterialTheme.typography.headlineSmall) + if (!tokenPresent.value) { + OutlinedTextField( + value = email.value, + onValueChange = { email.value = it }, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = password.value, + onValueChange = { password.value = it }, + label = { Text("Password") }, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { + scope.launch { + try { + val res = + apollo + .mutation( + LoginMutation( + email = email.value, + password = password.value, + ), + ) + .execute() + val t = res.data?.login?.accessToken + if (!t.isNullOrBlank()) { + prefs.edit().putString("accessToken", t).apply() + tokenPresent.value = true + status.value = "ok" + } else { + status.value = res.errors?.joinToString { it.message } ?: "login failed" + } + } catch (e: ApolloException) { + status.value = e.message ?: "network" + } + } + }, + ) { + Text("Login") + } + } else { + Row { + Button(onClick = { loadChats() }) { Text("Refresh chats") } + Button( + onClick = { + prefs.edit().remove("accessToken").apply() + tokenPresent.value = false + }, + ) { + Text("Logout") + } + } + LazyColumn(modifier = Modifier.weight(1f)) { + items(chats.value) { c -> + Button( + onClick = { activeId.value = c.id }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("${c.__typename} ${c.id} (${c.unreadCount} unread)") + } + } + } + OutlinedTextField( + value = draft.value, + onValueChange = { draft.value = it }, + label = { Text("Message") }, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { + val id = activeId.value + if (id == null) { + status.value = "pick a chat" + return@Button + } + val body = draft.value + outbox.enqueue("send:$id:${body.length}") + scope.launch { + try { + apollo + .mutation( + SendMessageMutation( + chatId = id, + body = + if (body.isBlank()) Optional.absent() + else Optional.present(body), + ), + ) + .execute() + draft.value = "" + loadChats() + } catch (e: ApolloException) { + status.value = e.message ?: "send failed" + } + } + }, + ) { + Text("Send") + } + } + Text(status.value, modifier = Modifier.padding(top = 8.dp)) + } + } + } + } +} diff --git a/apps/android/app/src/main/java/com/tlm/android/data/OutboxRepository.kt b/apps/android/app/src/main/java/com/tlm/android/data/OutboxRepository.kt new file mode 100644 index 0000000..6f70be3 --- /dev/null +++ b/apps/android/app/src/main/java/com/tlm/android/data/OutboxRepository.kt @@ -0,0 +1,23 @@ +package com.tlm.android.data + +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * Minimal outbox for optimistic sends; drain with WorkManager in a real build. + */ +class OutboxRepository { + private val q = ConcurrentLinkedQueue() + + fun enqueue(payload: String) { + q.add(payload) + } + + fun drain(max: Int = 50): List { + val out = ArrayList() + repeat(max) { + val v = q.poll() ?: return out + out.add(v) + } + return out + } +} diff --git a/apps/android/app/src/main/java/com/tlm/android/push/FcmRegistrationWorker.kt b/apps/android/app/src/main/java/com/tlm/android/push/FcmRegistrationWorker.kt new file mode 100644 index 0000000..6b87b69 --- /dev/null +++ b/apps/android/app/src/main/java/com/tlm/android/push/FcmRegistrationWorker.kt @@ -0,0 +1,15 @@ +package com.tlm.android.push + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters + +/** + * Placeholder for FCM token registration against your GraphQL `registerDevice` mutation. + */ +class FcmRegistrationWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result = Result.success() +} diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ce823c7 --- /dev/null +++ b/apps/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + TLM + diff --git a/apps/android/app/src/main/res/values/themes.xml b/apps/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1e00874 --- /dev/null +++ b/apps/android/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +