A Minecraft Fabric mod that connects the game with ATProtocol ⛏️
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: redesign config screen for ModMenu, remove /atproto config/gui

AtProtoConfigScreen is now a proper settings panel with all
ClientPreferences fields exposed:

- Authentication section (login/logout with status)
- Sync consent toggles (stats, sessions, achievements, server status)
- Sync frequency cycle buttons (1/5/10/15/30/60/120/240 min)
- UI preferences (notifications, F3 status, compact layout)
- Privacy settings (encrypted storage, clear on logout)
- Reset to Defaults button

Removes /atproto config and /atproto gui commands — ModMenu is
now the canonical entry point for settings. Help text updated
to point to Mod Menu instead.

+335 -319
+3 -25
src/client/kotlin/com/jollywhoppers/atproto/client/ClientAtProtoCommands.kt
··· 4 4 import com.jollywhoppers.atproto.oauth.OAuthManager 5 5 import com.jollywhoppers.config.PreferencesManager 6 6 import com.jollywhoppers.network.AtProtoPackets 7 - import com.jollywhoppers.screen.AtProtoConfigScreen 8 7 import com.mojang.brigadier.CommandDispatcher 9 8 import com.mojang.brigadier.arguments.StringArgumentType 10 9 import com.mojang.brigadier.context.CommandContext ··· 85 84 .then( 86 85 ClientCommandManager.literal("help") 87 86 .executes { context -> help(context) } 88 - ) 89 - .then( 90 - ClientCommandManager.literal("config") 91 - .executes { context -> openConfigScreen(context) } 92 - ) 93 - .then( 94 - ClientCommandManager.literal("gui") 95 - .executes { context -> openConfigScreen(context) } 96 87 ) 97 88 .executes { context -> help(context) } 98 89 ) ··· 265 256 } 266 257 267 258 /** 268 - * Opens the configuration/login screen. 269 - */ 270 - private fun openConfigScreen(context: CommandContext<FabricClientCommandSource>): Int { 271 - Minecraft.getInstance().execute { 272 - Minecraft.getInstance().setScreen(AtProtoConfigScreen(Minecraft.getInstance().screen)) 273 - } 274 - return 1 275 - } 276 - 277 - /** 278 259 * Shows current sync consent settings. 279 260 * Reads from local client preferences and sends the current state 280 261 * to the server via SyncPreferencesPacket. ··· 381 362 private fun help(context: CommandContext<FabricClientCommandSource>): Int { 382 363 context.source.sendFeedback( 383 364 Component.literal("§b━━━ AT Protocol Commands (Client-Side) ━━━") 384 - .append(Component.literal("\n§f/atproto config §7or §f/atproto gui")) 385 - .append(Component.literal("\n §7Open the settings GUI (Recommended!)")) 386 - .append(Component.literal("\n §7Easy login interface with no typing needed")) 387 - .append(Component.literal("\n")) 388 365 .append(Component.literal("\n§f/atproto login <handle>")) 389 366 .append(Component.literal("\n §7OAuth browser login (Recommended!)")) 390 367 .append(Component.literal("\n §7Opens your browser for secure authorisation")) ··· 405 382 .append(Component.literal("\n§f/atproto status")) 406 383 .append(Component.literal("\n §7Check your authentication status")) 407 384 .append(Component.literal("\n")) 408 - .append(Component.literal("\n§e💡 Tip: You can also access settings via Mod Menu!")) 409 - .append(Component.literal("\n§eNote: Authentication happens entirely on your computer.")) 385 + .append(Component.literal("\n§eAll settings (sync consent, frequencies, privacy)")) 386 + .append(Component.literal("\n§eare available via Mod Menu in the mods list.")) 387 + .append(Component.literal("\n§eAuthentication happens entirely on your computer.")) 410 388 .append(Component.literal("\n§eYour password never leaves your machine!")) 411 389 ) 412 390 return 1
+332 -294
src/client/kotlin/com/jollywhoppers/screen/AtProtoConfigScreen.kt
··· 2 2 3 3 import com.jollywhoppers.AtprotoconnectClient 4 4 import com.jollywhoppers.atproto.oauth.OAuthManager 5 + import com.jollywhoppers.config.ClientPreferences 5 6 import com.jollywhoppers.config.PreferencesManager 6 7 import com.jollywhoppers.network.AtProtoPackets 7 8 import kotlinx.coroutines.CoroutineScope ··· 17 18 import org.slf4j.LoggerFactory 18 19 19 20 /** 20 - * Configuration screen for ATProto Connect. 21 - * Provides GUI-based authentication with both OAuth and app-password login. 22 - * 23 - * Security Features: 24 - * - Client-side only authentication 25 - * - Passwords never sent to server 26 - * - OAuth browser-based login (recommended) 27 - * - App-password fallback 28 - * - Local session storage 29 - * - Clear visual feedback 21 + * ModMenu configuration screen for SocialSync. 22 + * Exposes all client preferences: authentication, sync consent, 23 + * sync frequency, UI preferences, and privacy settings. 30 24 */ 31 25 class AtProtoConfigScreen(private val parent: Screen?) : Screen(Component.literal("SocialSync Settings")) { 32 26 33 27 private val logger = LoggerFactory.getLogger("atproto-connect-ui") 34 28 private val coroutineScope = CoroutineScope(Dispatchers.IO) 35 29 36 - // UI Components 30 + // Session state 31 + private val sessionManager = AtprotoconnectClient.sessionManager 32 + private val oAuthManager = AtprotoconnectClient.oAuthManager 33 + 34 + // Auth fields (only shown when not logged in) 37 35 private var handleField: EditBox? = null 38 36 private var passwordField: EditBox? = null 39 - private var oauthButton: Button? = null 40 - private var loginButton: Button? = null 41 - private var logoutButton: Button? = null 42 37 private var statusText: Component? = null 43 38 private var isAuthenticating = false 44 39 45 - // Session state 46 - private val sessionManager = AtprotoconnectClient.sessionManager 47 - private val oAuthManager = AtprotoconnectClient.oAuthManager 40 + // Frequency step values 41 + private val frequencySteps = intArrayOf(1, 5, 10, 15, 30, 60, 120, 240) 48 42 49 43 override fun init() { 50 44 super.init() 51 45 52 46 val centerX = width / 2 53 - val startY = 60 47 + val prefs = PreferencesManager.get() 48 + var y = 40 54 49 55 - // Handle input field 56 - handleField = EditBox( 57 - minecraft!!.font, 58 - centerX - 150, 59 - startY, 60 - 300, 61 - 20, 62 - Component.literal("Handle or DID") 63 - ).apply { 64 - setHint(Component.literal("alice.bsky.social or did:plc:...")) 65 - setMaxLength(256) 50 + // ── Authentication ────────────────────────────────────── 51 + y = addAuthSection(centerX, y, prefs) 52 + 53 + // ── Sync Consent ──────────────────────────────────────── 54 + y = addSyncConsentSection(centerX, y, prefs) 55 + 56 + // ── Sync Frequency ─────────────────────────────────────── 57 + y = addSyncFrequencySection(centerX, y, prefs) 66 58 67 - if (!sessionManager.hasSession()) { 68 - value = "" 69 - } 70 - } 71 - addRenderableWidget(handleField!!) 59 + // ── UI Preferences ────────────────────────────────────── 60 + y = addUISection(centerX, y, prefs) 72 61 73 - // Password input field (for app-password fallback) 74 - passwordField = EditBox( 75 - minecraft!!.font, 76 - centerX - 150, 77 - startY + 30, 78 - 300, 79 - 20, 80 - Component.literal("App Password") 81 - ).apply { 82 - setHint(Component.literal("App Password (optional — for fallback login)")) 83 - setMaxLength(256) 84 - } 85 - addRenderableWidget(passwordField!!) 62 + // ── Privacy ───────────────────────────────────────────── 63 + y = addPrivacySection(centerX, y, prefs) 86 64 87 - // OAuth Login button (recommended, primary) 88 - oauthButton = Button.builder( 89 - Component.literal("OAuth Login (Recommended)"), 90 - Button.OnPress { onOAuthClicked() } 65 + // ── Bottom bar ────────────────────────────────────────── 66 + addRenderableWidget( 67 + Button.builder( 68 + Component.literal("Done"), 69 + Button.OnPress { onClose() } 70 + ) 71 + .bounds(centerX - 155, height - 28, 150, 20) 72 + .build() 91 73 ) 92 - .bounds(centerX - 155, startY + 60, 150, 20) 93 - .build() 94 - .also { addRenderableWidget(it) } 95 74 96 - // App-password Login button (fallback) 97 - loginButton = Button.builder( 98 - Component.literal("App Password Login"), 99 - Button.OnPress { onLoginClicked() } 75 + addRenderableWidget( 76 + Button.builder( 77 + Component.literal("Reset Defaults"), 78 + Button.OnPress { onResetDefaults() } 79 + ) 80 + .bounds(centerX + 5, height - 28, 150, 20) 81 + .build() 100 82 ) 101 - .bounds(centerX + 5, startY + 60, 150, 20) 102 - .build() 103 - .also { addRenderableWidget(it) } 83 + } 84 + 85 + // ── Section builders ──────────────────────────────────────── 86 + 87 + private fun addAuthSection(centerX: Int, startY: Int, prefs: ClientPreferences): Int { 88 + var y = startY 89 + val hasSession = sessionManager.hasSession() 90 + 91 + if (hasSession) { 92 + // Show current session info + logout 93 + val isOAuth = sessionManager.isOAuthSession() 94 + val authLabel = if (isOAuth) "§bOAuth" else "§eApp Password" 95 + 96 + coroutineScope.launch { 97 + try { 98 + val session = sessionManager.getSession().getOrThrow() 99 + minecraft?.execute { 100 + statusText = Component.literal("§aLogged in ($authLabel§a)") 101 + .append(Component.literal("\n§7Handle: §f${session.handle}")) 102 + .append(Component.literal("\n§7DID: §f${session.did}")) 103 + } 104 + } catch (_: Exception) { 105 + minecraft?.execute { 106 + statusText = Component.literal("§eSession may be expired") 107 + } 108 + } 109 + } 110 + 111 + addRenderableWidget( 112 + Button.builder( 113 + Component.literal("Logout"), 114 + Button.OnPress { onLogoutClicked() } 115 + ) 116 + .bounds(centerX - 75, y + 36, 150, 20) 117 + .build() 118 + ) 119 + 120 + y += 70 121 + } else { 122 + // Login fields 123 + handleField = EditBox( 124 + minecraft!!.font, 125 + centerX - 150, y + 12, 126 + 300, 20, 127 + Component.literal("Handle or DID") 128 + ).apply { 129 + setHint(Component.literal("alice.bsky.social or did:plc:...")) 130 + setMaxLength(256) 131 + } 132 + addRenderableWidget(handleField!!) 133 + 134 + passwordField = EditBox( 135 + minecraft!!.font, 136 + centerX - 150, y + 42, 137 + 300, 20, 138 + Component.literal("App Password") 139 + ).apply { 140 + setHint(Component.literal("App Password (for fallback login)")) 141 + setMaxLength(256) 142 + } 143 + addRenderableWidget(passwordField!!) 144 + 145 + addRenderableWidget( 146 + Button.builder( 147 + Component.literal("OAuth Login"), 148 + Button.OnPress { onOAuthClicked() } 149 + ) 150 + .bounds(centerX - 155, y + 70, 150, 20) 151 + .build() 152 + ) 153 + 154 + addRenderableWidget( 155 + Button.builder( 156 + Component.literal("App Password Login"), 157 + Button.OnPress { onLoginClicked() } 158 + ) 159 + .bounds(centerX + 5, y + 70, 150, 20) 160 + .build() 161 + ) 104 162 105 - // Logout button 106 - logoutButton = Button.builder( 107 - Component.literal("Logout"), 108 - Button.OnPress { onLogoutClicked() } 109 - ) 110 - .bounds(centerX - 75, startY + 90, 150, 20) 111 - .build() 112 - .also { addRenderableWidget(it) } 163 + y += 100 164 + } 113 165 114 - // Sync consent toggles 115 - val syncStartY = startY + 125 116 - val prefs = PreferencesManager.get() 166 + return y 167 + } 117 168 118 - // Stats toggle 169 + private fun addSyncConsentSection(centerX: Int, startY: Int, prefs: ClientPreferences): Int { 170 + var y = startY 171 + 119 172 addRenderableWidget( 120 173 Button.builder( 121 174 Component.literal("Stats: ${if (prefs.syncStatsEnabled) "§aOn" else "§cOff"}"), 122 175 Button.OnPress { toggleSyncConsent("stats") } 123 176 ) 124 - .bounds(centerX - 155, syncStartY, 150, 20) 177 + .bounds(centerX - 155, y, 150, 20) 125 178 .build() 126 179 ) 127 180 128 - // Sessions toggle 129 181 addRenderableWidget( 130 182 Button.builder( 131 183 Component.literal("Sessions: ${if (prefs.syncSessionsEnabled) "§aOn" else "§cOff"}"), 132 184 Button.OnPress { toggleSyncConsent("sessions") } 133 185 ) 134 - .bounds(centerX + 5, syncStartY, 150, 20) 186 + .bounds(centerX + 5, y, 150, 20) 135 187 .build() 136 188 ) 137 189 138 - // Achievements toggle 190 + y += 24 191 + 139 192 addRenderableWidget( 140 193 Button.builder( 141 194 Component.literal("Achievements: ${if (prefs.syncAchievementsEnabled) "§aOn" else "§cOff"}"), 142 195 Button.OnPress { toggleSyncConsent("achievements") } 143 196 ) 144 - .bounds(centerX - 155, syncStartY + 25, 150, 20) 197 + .bounds(centerX - 155, y, 150, 20) 145 198 .build() 146 199 ) 147 200 148 - // Server status toggle 149 201 addRenderableWidget( 150 202 Button.builder( 151 203 Component.literal("Server Status: ${if (prefs.syncServerStatusEnabled) "§aOn" else "§cOff"}"), 152 204 Button.OnPress { toggleSyncConsent("server-status") } 153 205 ) 154 - .bounds(centerX + 5, syncStartY + 25, 150, 20) 206 + .bounds(centerX + 5, y, 150, 20) 155 207 .build() 156 208 ) 157 209 158 - // Done button 210 + return y + 30 211 + } 212 + 213 + private fun addSyncFrequencySection(centerX: Int, startY: Int, prefs: ClientPreferences): Int { 214 + var y = startY 215 + 159 216 addRenderableWidget( 160 217 Button.builder( 161 - Component.literal("Done"), 162 - Button.OnPress { onClose() } 218 + Component.literal("Stats: ${prefs.statsSyncFrequency}m"), 219 + Button.OnPress { cycleFrequency("stats") } 163 220 ) 164 - .bounds(centerX - 75, height - 30, 150, 20) 221 + .bounds(centerX - 155, y, 150, 20) 165 222 .build() 166 223 ) 167 224 168 - // Help button 169 225 addRenderableWidget( 170 226 Button.builder( 171 - Component.literal("Help"), 172 - Button.OnPress { onHelpClicked() } 227 + Component.literal("Sessions: ${prefs.sessionSyncFrequency}m"), 228 + Button.OnPress { cycleFrequency("sessions") } 173 229 ) 174 - .bounds(centerX + 85, height - 30, 70, 20) 230 + .bounds(centerX + 5, y, 150, 20) 175 231 .build() 176 232 ) 177 233 178 - updateUIState() 234 + y += 24 235 + 236 + addRenderableWidget( 237 + Button.builder( 238 + Component.literal("Achievements: ${prefs.achievementSyncFrequency}m"), 239 + Button.OnPress { cycleFrequency("achievements") } 240 + ) 241 + .bounds(centerX - 155, y, 150, 20) 242 + .build() 243 + ) 244 + 245 + return y + 30 179 246 } 180 247 181 - override fun render(graphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { 182 - // Render background 183 - renderBackground(graphics, mouseX, mouseY, partialTick) 248 + private fun addUISection(centerX: Int, startY: Int, prefs: ClientPreferences): Int { 249 + var y = startY 184 250 185 - // Render title 186 - graphics.drawCenteredString( 187 - font, 188 - title, 189 - width / 2, 190 - 15, 191 - 0xFFFFFF 251 + addRenderableWidget( 252 + Button.builder( 253 + Component.literal("Notifications: ${if (prefs.showSyncNotifications) "§aOn" else "§cOff"}"), 254 + Button.OnPress { togglePreference("showSyncNotifications") } 255 + ) 256 + .bounds(centerX - 155, y, 150, 20) 257 + .build() 192 258 ) 193 259 194 - // Render labels 195 - graphics.drawString( 196 - font, 197 - "Handle or DID:", 198 - width / 2 - 150, 199 - 48, 200 - 0xA0A0A0 260 + addRenderableWidget( 261 + Button.builder( 262 + Component.literal("F3 Status: ${if (prefs.showStatusInF3) "§aOn" else "§cOff"}"), 263 + Button.OnPress { togglePreference("showStatusInF3") } 264 + ) 265 + .bounds(centerX + 5, y, 150, 20) 266 + .build() 201 267 ) 202 268 203 - graphics.drawString( 204 - font, 205 - "App Password (optional):", 206 - width / 2 - 150, 207 - 78, 208 - 0xA0A0A0 269 + y += 24 270 + 271 + addRenderableWidget( 272 + Button.builder( 273 + Component.literal("Compact Layout: ${if (prefs.compactModMenuLayout) "§aOn" else "§cOff"}"), 274 + Button.OnPress { togglePreference("compactModMenuLayout") } 275 + ) 276 + .bounds(centerX - 155, y, 150, 20) 277 + .build() 209 278 ) 210 279 211 - // Sync consent label 212 - graphics.drawCenteredString( 213 - font, 214 - "Sync Consent", 215 - width / 2, 216 - 178, 217 - 0xA0A0A0 280 + return y + 30 281 + } 282 + 283 + private fun addPrivacySection(centerX: Int, startY: Int, prefs: ClientPreferences): Int { 284 + var y = startY 285 + 286 + addRenderableWidget( 287 + Button.builder( 288 + Component.literal("Encrypt Storage: ${if (prefs.encryptedLocalStorage) "§aOn" else "§cOff"}"), 289 + Button.OnPress { togglePreference("encryptedLocalStorage") } 290 + ) 291 + .bounds(centerX - 155, y, 150, 20) 292 + .build() 218 293 ) 219 294 220 - // Render status 295 + addRenderableWidget( 296 + Button.builder( 297 + Component.literal("Clear on Logout: ${if (prefs.clearLocalCacheOnLogout) "§aOn" else "§cOff"}"), 298 + Button.OnPress { togglePreference("clearLocalCacheOnLogout") } 299 + ) 300 + .bounds(centerX + 5, y, 150, 20) 301 + .build() 302 + ) 303 + 304 + return y + 30 305 + } 306 + 307 + // ── Rendering ──────────────────────────────────────────────── 308 + 309 + override fun render(graphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { 310 + renderBackground(graphics, mouseX, mouseY, partialTick) 311 + 312 + // Title 313 + graphics.drawCenteredString(font, title, width / 2, 12, 0xFFFFFF) 314 + 315 + // Section headers 316 + val prefs = PreferencesManager.get() 317 + val hasSession = sessionManager.hasSession() 318 + var headerY = 30 319 + 320 + // Auth header 321 + graphics.drawCenteredString(font, "§nAuthentication", width / 2, headerY, 0xA0A0A0) 322 + 323 + // Sync consent header 324 + headerY = if (hasSession) 110 else 140 325 + graphics.drawCenteredString(font, "§nSync Consent", width / 2, headerY, 0xA0A0A0) 326 + 327 + // Sync frequency header 328 + headerY += 78 329 + graphics.drawCenteredString(font, "§nSync Frequency", width / 2, headerY, 0xA0A0A0) 330 + 331 + // UI header 332 + headerY += 60 333 + graphics.drawCenteredString(font, "§nUI", width / 2, headerY, 0xA0A0A0) 334 + 335 + // Privacy header 336 + headerY += 60 337 + graphics.drawCenteredString(font, "§nPrivacy", width / 2, headerY, 0xA0A0A0) 338 + 339 + // Auth status text 221 340 statusText?.let { status -> 222 341 val lines = font.split(status, width - 40) 223 - var y = 130 342 + var y = 46 224 343 for (line in lines) { 225 - graphics.drawCenteredString( 226 - font, 227 - line, 228 - width / 2, 229 - y, 230 - 0xFFFFFF 231 - ) 344 + graphics.drawCenteredString(font, line, width / 2, y, 0xFFFFFF) 232 345 y += 12 233 346 } 234 347 } 235 348 236 - // Render security notice 349 + // Labels for auth fields (when not logged in) 350 + if (!hasSession) { 351 + graphics.drawString(font, "Handle or DID:", width / 2 - 150, 42, 0xA0A0A0) 352 + graphics.drawString(font, "App Password:", width / 2 - 150, 72, 0xA0A0A0) 353 + } 354 + 355 + // Security notice 237 356 val securityNotice = if (sessionManager.isOAuthSession()) { 238 - Component.literal("OAuth session active — your data is secure") 357 + Component.literal("§aOAuth session active") 239 358 } else if (sessionManager.hasSession()) { 240 - Component.literal("App-password session — your password never leaves your computer") 359 + Component.literal("§7App-password session — password never leaves your computer") 241 360 } else { 242 - Component.literal("OAuth is recommended — no password needed!") 361 + Component.literal("§eOAuth is recommended — no password needed") 243 362 } 244 - graphics.drawCenteredString( 245 - font, 246 - securityNotice, 247 - width / 2, 248 - height - 50, 249 - 0x40FF40 250 - ) 363 + graphics.drawCenteredString(font, securityNotice, width / 2, height - 48, 0x40FF40) 251 364 252 365 super.render(graphics, mouseX, mouseY, partialTick) 253 366 } 254 367 255 - /** 256 - * OAuth browser-based login. 257 - * Opens the user's browser for ATProto OAuth authorization. 258 - */ 368 + // ── Auth actions ───────────────────────────────────────────── 369 + 259 370 private fun onOAuthClicked() { 260 371 val handle = handleField?.value?.trim() ?: "" 261 - 262 372 if (handle.isEmpty()) { 263 - statusText = Component.literal("§cPlease enter your handle or DID") 373 + statusText = Component.literal("§cEnter your handle or DID") 264 374 return 265 375 } 266 376 267 377 isAuthenticating = true 268 - oauthButton?.active = false 269 - loginButton?.active = false 270 - logoutButton?.active = false 271 378 statusText = Component.literal("§eOpening browser for OAuth...") 272 - .append(Component.literal("\n§7Please complete login in your browser")) 273 379 274 380 coroutineScope.launch { 275 381 try { 276 382 val result = oAuthManager.authorize(handle).getOrThrow() 277 383 val session = result.session 278 384 279 - // Send authenticated session to server for verification 280 385 val packet = AtProtoPackets.AuthenticatePacket( 281 386 did = session.did, 282 387 handle = session.handle, ··· 285 390 refreshJwt = session.refreshToken, 286 391 authType = "oauth", 287 392 ) 288 - 289 393 ClientPlayNetworking.send(packet) 290 - 291 - // Store OAuth session locally 292 394 sessionManager.storeOAuthSession(session, result.dpopKeyPair) 293 395 294 396 minecraft?.execute { 295 397 statusText = Component.literal("§aOAuth authorisation successful!") 296 398 .append(Component.literal("\n§7Handle: §f${session.handle}")) 297 - .append(Component.literal("\n§7DID: §f${session.did}")) 298 - .append(Component.literal("\n§7Scope: §f${session.scope}")) 299 399 .append(Component.literal("\n§eWaiting for server confirmation...")) 300 - 301 - // Also send to chat for better visibility 302 - minecraft?.gui?.chat?.addMessage( 303 - Component.literal("§aOAuth login successful!") 304 - .append(Component.literal("\n§7Handle: §f${session.handle}")) 305 - ) 306 - 307 400 passwordField?.value = "" 308 - updateUIState() 309 - logger.info("OAuth authenticated as ${session.handle}, sent session to server") 401 + rebuildWidgets() 402 + logger.info("OAuth authenticated as ${session.handle}") 310 403 } 311 404 } catch (e: Exception) { 312 405 minecraft?.execute { 313 - statusText = Component.literal("§cOAuth authorisation failed") 314 - .append(Component.literal("\n§7${e.message ?: "Unknown error"}")) 315 - .append(Component.literal("\n\n§7Try app-password login instead")) 316 - 317 - minecraft?.gui?.chat?.addMessage( 318 - Component.literal("§cOAuth failed: ${e.message ?: "Unknown error"}") 319 - ) 320 - 321 - updateUIState() 322 - logger.error("OAuth authorisation failed: ${e.javaClass.simpleName} - ${e.message}") 406 + statusText = Component.literal("§cOAuth failed: ${e.message ?: "Unknown error"}") 407 + rebuildWidgets() 408 + logger.error("OAuth failed: ${e.javaClass.simpleName}") 323 409 } 324 410 } 325 411 } 326 412 } 327 413 328 - /** 329 - * App-password login (fallback). 330 - */ 331 414 private fun onLoginClicked() { 332 415 val handle = handleField?.value?.trim() ?: "" 333 416 val password = passwordField?.value ?: "" 334 417 335 418 if (handle.isEmpty()) { 336 - statusText = Component.literal("§cPlease enter your handle or DID") 419 + statusText = Component.literal("§cEnter your handle or DID") 337 420 return 338 421 } 339 - 340 422 if (password.isEmpty()) { 341 - statusText = Component.literal("§cPlease enter your app password") 423 + statusText = Component.literal("§cEnter your app password") 342 424 return 343 425 } 344 426 345 427 isAuthenticating = true 346 - oauthButton?.active = false 347 - loginButton?.active = false 348 - logoutButton?.active = false 349 - statusText = Component.literal("§eAuthenticating with AT Protocol...") 428 + statusText = Component.literal("§eAuthenticating...") 350 429 351 430 coroutineScope.launch { 352 431 try { 353 - // Authenticate with AT Protocol servers (client-side only) 354 432 val session = sessionManager.createSession(handle, password).getOrThrow() 355 433 356 - // Send authenticated session to server for verification 357 434 val packet = AtProtoPackets.AuthenticatePacket( 358 435 did = session.did, 359 436 handle = session.handle, ··· 364 441 ) 365 442 ClientPlayNetworking.send(packet) 366 443 367 - // Update UI on main thread 368 444 minecraft?.execute { 369 - statusText = Component.literal("§aAuthenticated successfully!") 445 + statusText = Component.literal("§aAuthenticated!") 370 446 .append(Component.literal("\n§7Handle: §f${session.handle}")) 371 - .append(Component.literal("\n§7DID: §f${session.did}")) 372 - 373 - minecraft?.gui?.chat?.addMessage( 374 - Component.literal("§aAuthenticated with AT Protocol!") 375 - .append(Component.literal("\n§7Waiting for server confirmation...")) 376 - ) 377 - 378 - // Clear password field 447 + .append(Component.literal("\n§eWaiting for server confirmation...")) 379 448 passwordField?.value = "" 380 - 381 - updateUIState() 382 - 383 - logger.info("Successfully authenticated as ${session.handle}, sent session to server") 449 + rebuildWidgets() 450 + logger.info("Authenticated as ${session.handle}") 384 451 } 385 452 } catch (e: Exception) { 386 453 minecraft?.execute { 387 - statusText = Component.literal("§cAuthentication failed") 388 - .append(Component.literal("\n§7${e.message ?: "Unknown error"}")) 389 - .append(Component.literal("\n\n§7Tip: Use an §fApp Password§7 from your")) 390 - .append(Component.literal("\n§7AT Protocol account settings")) 391 - .append(Component.literal("\n§c§lNever use your main password!")) 392 - 393 - minecraft?.gui?.chat?.addMessage( 394 - Component.literal("§cAuthentication failed: ${e.message ?: "Unknown error"}") 395 - ) 396 - 397 - updateUIState() 398 - 399 - logger.error("Authentication failed: ${e.javaClass.simpleName} - ${e.message}") 454 + statusText = Component.literal("§cAuthentication failed: ${e.message ?: "Unknown error"}") 455 + rebuildWidgets() 456 + logger.error("Auth failed: ${e.javaClass.simpleName}") 400 457 } 401 458 } 402 459 } ··· 404 461 405 462 private fun onLogoutClicked() { 406 463 sessionManager.deleteSession() 407 - 408 - // Notify server 409 - val packet = AtProtoPackets.LogoutPacket() 410 - ClientPlayNetworking.send(packet) 411 - 412 - statusText = Component.literal("§aLogged out successfully") 413 - .append(Component.literal("\n§7Session cleared from your computer")) 414 - 415 - minecraft?.gui?.chat?.addMessage( 416 - Component.literal("§aLogged out from SocialSync") 417 - ) 418 - 419 - // Clear fields 464 + ClientPlayNetworking.send(AtProtoPackets.LogoutPacket()) 465 + statusText = Component.literal("§aLogged out") 420 466 handleField?.value = "" 421 467 passwordField?.value = "" 468 + rebuildWidgets() 469 + logger.info("Logged out") 470 + } 422 471 423 - updateUIState() 472 + // ── Preference toggles ─────────────────────────────────────── 424 473 425 - logger.info("Logged out, notified server") 426 - } 427 - 428 - /** 429 - * Toggles a sync consent category and sends the change to the server. 430 - */ 431 474 private fun toggleSyncConsent(category: String) { 432 475 val prefs = PreferencesManager.get() 433 476 val newValue = when (category) { ··· 445 488 "server-status" -> PreferencesManager.updateSyncConsent(serverStatus = newValue) 446 489 } 447 490 448 - // Send updated preferences to server 449 - val updated = PreferencesManager.get() 450 - val packet = AtProtoPackets.SyncPreferencesPacket( 451 - syncStatsEnabled = updated.syncStatsEnabled, 452 - syncSessionsEnabled = updated.syncSessionsEnabled, 453 - syncAchievementsEnabled = updated.syncAchievementsEnabled, 454 - syncServerStatusEnabled = updated.syncServerStatusEnabled, 455 - statsSyncFrequency = updated.statsSyncFrequency, 456 - sessionSyncFrequency = updated.sessionSyncFrequency, 457 - achievementSyncFrequency = updated.achievementSyncFrequency, 458 - ) 459 - ClientPlayNetworking.send(packet) 460 - 461 - // Rebuild screen to update button labels 491 + sendPreferencesToServer() 462 492 rebuildWidgets() 463 493 } 464 494 465 - private fun onHelpClicked() { 466 - val helpText = Component.literal("§e§lSocialSync Login Help") 467 - .append(Component.literal("\n\n§b§lOAuth Login (Recommended)")) 468 - .append(Component.literal("\n§71. Enter your handle (e.g. alice.bsky.social)")) 469 - .append(Component.literal("\n§72. Click §fOAuth Login§7")) 470 - .append(Component.literal("\n§73. Complete login in your browser")) 471 - .append(Component.literal("\n§74. Return to Minecraft")) 472 - .append(Component.literal("\n\n§b§lApp Password Login (Fallback)")) 473 - .append(Component.literal("\n§71. Go to your AT Protocol account settings")) 474 - .append(Component.literal("\n §7(e.g. Bluesky → Settings → Privacy & Security)")) 475 - .append(Component.literal("\n§72. Find \"App Passwords\"")) 476 - .append(Component.literal("\n§73. Create a new app password")) 477 - .append(Component.literal("\n§74. Enter it in the password field above")) 478 - .append(Component.literal("\n\n§c§lNEVER use your main account password!")) 479 - .append(Component.literal("\n§cApp Passwords can be revoked anytime.")) 480 - 481 - statusText = helpText 495 + private fun togglePreference(key: String) { 496 + val prefs = PreferencesManager.get() 497 + val updated = when (key) { 498 + "showSyncNotifications" -> prefs.copy(showSyncNotifications = !prefs.showSyncNotifications) 499 + "showStatusInF3" -> prefs.copy(showStatusInF3 = !prefs.showStatusInF3) 500 + "compactModMenuLayout" -> prefs.copy(compactModMenuLayout = !prefs.compactModMenuLayout) 501 + "encryptedLocalStorage" -> prefs.copy(encryptedLocalStorage = !prefs.encryptedLocalStorage) 502 + "clearLocalCacheOnLogout" -> prefs.copy(clearLocalCacheOnLogout = !prefs.clearLocalCacheOnLogout) 503 + else -> return 504 + } 505 + PreferencesManager.update(updated) 506 + rebuildWidgets() 482 507 } 483 508 484 - private fun updateUIState() { 485 - val hasSession = sessionManager.hasSession() 509 + private fun cycleFrequency(category: String) { 510 + val prefs = PreferencesManager.get() 511 + val current = when (category) { 512 + "stats" -> prefs.statsSyncFrequency 513 + "sessions" -> prefs.sessionSyncFrequency 514 + "achievements" -> prefs.achievementSyncFrequency 515 + else -> return 516 + } 486 517 487 - isAuthenticating = false 518 + // Find next step, wrapping around 519 + val nextIndex = (frequencySteps.indexOfFirst { it > current }.takeIf { it >= 0 } ?: 0) 520 + val nextValue = frequencySteps[nextIndex] 488 521 489 - handleField?.active = !hasSession 490 - passwordField?.active = !hasSession 491 - oauthButton?.active = !hasSession && !isAuthenticating 492 - loginButton?.active = !hasSession && !isAuthenticating 493 - logoutButton?.active = hasSession 494 - 495 - if (hasSession && statusText == null) { 496 - // Show current session info 497 - coroutineScope.launch { 498 - try { 499 - val session = sessionManager.getSession().getOrThrow() 500 - val authLabel = if (sessionManager.isOAuthSession()) "§bOAuth" else "§eApp Password" 501 - minecraft?.execute { 502 - statusText = Component.literal("§aCurrently logged in ($authLabel§a)") 503 - .append(Component.literal("\n§7Handle: §f${session.handle}")) 504 - .append(Component.literal("\n§7DID: §f${session.did}")) 505 - } 506 - } catch (e: Exception) { 507 - minecraft?.execute { 508 - statusText = Component.literal("§eSession may be expired") 509 - .append(Component.literal("\n§7Try logging out and back in")) 510 - } 511 - } 512 - } 522 + val updated = when (category) { 523 + "stats" -> prefs.copy(statsSyncFrequency = nextValue) 524 + "sessions" -> prefs.copy(sessionSyncFrequency = nextValue) 525 + "achievements" -> prefs.copy(achievementSyncFrequency = nextValue) 526 + else -> return 513 527 } 528 + PreferencesManager.update(updated) 529 + sendPreferencesToServer() 530 + rebuildWidgets() 514 531 } 515 532 533 + private fun sendPreferencesToServer() { 534 + val prefs = PreferencesManager.get() 535 + val packet = AtProtoPackets.SyncPreferencesPacket( 536 + syncStatsEnabled = prefs.syncStatsEnabled, 537 + syncSessionsEnabled = prefs.syncSessionsEnabled, 538 + syncAchievementsEnabled = prefs.syncAchievementsEnabled, 539 + syncServerStatusEnabled = prefs.syncServerStatusEnabled, 540 + statsSyncFrequency = prefs.statsSyncFrequency, 541 + sessionSyncFrequency = prefs.sessionSyncFrequency, 542 + achievementSyncFrequency = prefs.achievementSyncFrequency, 543 + ) 544 + ClientPlayNetworking.send(packet) 545 + } 546 + 547 + private fun onResetDefaults() { 548 + PreferencesManager.reset() 549 + sendPreferencesToServer() 550 + rebuildWidgets() 551 + logger.info("Preferences reset to defaults") 552 + } 553 + 554 + // ── Screen lifecycle ───────────────────────────────────────── 555 + 516 556 override fun onClose() { 517 557 minecraft?.setScreen(parent) 518 558 } 519 559 520 - override fun isPauseScreen(): Boolean { 521 - return true 522 - } 560 + override fun isPauseScreen(): Boolean = true 523 561 }