diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMConversationScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMConversationScreen.kt
index 5f48fed26b..14afa2532a 100644
--- a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMConversationScreen.kt
+++ b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMConversationScreen.kt
@@ -83,9 +83,6 @@ internal fun DMConversationScreen(
toProfile: (MicroBlogKey) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) {
- focusRequester.requestFocus()
- }
val state by producePresenter(
key = "dm_conversation_${accountType}_$roomKey",
) {
@@ -94,6 +91,11 @@ internal fun DMConversationScreen(
roomKey = roomKey,
)
}
+ LaunchedEffect(state.pinCodePromptVisible) {
+ if (!state.pinCodePromptVisible) {
+ focusRequester.requestFocus()
+ }
+ }
FlareScaffold(
topBar = {
@@ -184,59 +186,61 @@ internal fun DMConversationScreen(
)
},
bottomBar = {
- Surface {
- Box {
- HorizontalDivider(
- modifier =
- Modifier
- .align(Alignment.TopCenter)
- .fillMaxWidth(),
- color = FlareDividerDefaults.color,
- thickness = FlareDividerDefaults.thickness,
- )
- OutlinedTextField(
- modifier =
- Modifier
- .padding(
- bottom = LocalBottomBarHeight.current,
- ).windowInsetsPadding(
- WindowInsets.systemBars.only(
- WindowInsetsSides.Horizontal,
- ),
- ).consumeWindowInsets(
- PaddingValues(
+ if (!state.pinCodePromptVisible) {
+ Surface {
+ Box {
+ HorizontalDivider(
+ modifier =
+ Modifier
+ .align(Alignment.TopCenter)
+ .fillMaxWidth(),
+ color = FlareDividerDefaults.color,
+ thickness = FlareDividerDefaults.thickness,
+ )
+ OutlinedTextField(
+ modifier =
+ Modifier
+ .padding(
bottom = LocalBottomBarHeight.current,
+ ).windowInsetsPadding(
+ WindowInsets.systemBars.only(
+ WindowInsetsSides.Horizontal,
+ ),
+ ).consumeWindowInsets(
+ PaddingValues(
+ bottom = LocalBottomBarHeight.current,
+ ),
+ ).imePadding()
+ .fillMaxWidth()
+ .padding(
+ horizontal = screenHorizontalPadding,
+ vertical = 8.dp,
+ ).focusRequester(
+ focusRequester = focusRequester,
),
- ).imePadding()
- .fillMaxWidth()
- .padding(
- horizontal = screenHorizontalPadding,
- vertical = 8.dp,
- ).focusRequester(
- focusRequester = focusRequester,
- ),
- state = state.text,
- lineLimits = TextFieldLineLimits.SingleLine,
- trailingIcon = {
- IconButton(
- onClick = {
- state.send()
- },
- enabled = state.canSend,
- ) {
- FAIcon(
- FontAwesomeIcons.Solid.PaperPlane,
- contentDescription = stringResource(id = R.string.send),
+ state = state.text,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ trailingIcon = {
+ IconButton(
+ onClick = {
+ state.send()
+ },
+ enabled = state.canSend,
+ ) {
+ FAIcon(
+ FontAwesomeIcons.Solid.PaperPlane,
+ contentDescription = stringResource(id = R.string.send),
+ )
+ }
+ },
+ shape = RoundedCornerShape(100),
+ placeholder = {
+ Text(
+ text = stringResource(id = R.string.dm_send_placeholder),
)
- }
- },
- shape = RoundedCornerShape(100),
- placeholder = {
- Text(
- text = stringResource(id = R.string.dm_send_placeholder),
- )
- },
- )
+ },
+ )
+ }
}
}
},
@@ -249,50 +253,59 @@ internal fun DMConversationScreen(
}
}
}
- LazyColumn(
- state = listState,
- reverseLayout = true,
- contentPadding = contentPadding,
- modifier =
- Modifier
- .consumeWindowInsets(contentPadding)
- .fillMaxSize()
- .imePadding()
- .imeNestedScroll(),
- verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom),
- ) {
- items(
- state.items,
- key = {
- it.id
- },
+ if (state.pinCodePromptVisible) {
+ DirectMessagePinCodeGate(
+ isVerifying = state.pinCodeVerifying,
+ errorMessage = state.pinCodeErrorMessage,
+ onSubmit = state::submitPinCode,
+ modifier = Modifier.padding(contentPadding),
+ )
+ } else {
+ LazyColumn(
+ state = listState,
+ reverseLayout = true,
+ contentPadding = contentPadding,
+ modifier =
+ Modifier
+ .consumeWindowInsets(contentPadding)
+ .fillMaxSize()
+ .imePadding()
+ .imeNestedScroll(),
+ verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom),
+ ) {
+ items(
+ state.items,
+ key = {
+ it.id
+ },
// emptyContent = {
//
// },
// errorContent = {
//
// },
- loadingContent = {
- DMLoadingItem()
- },
- itemContent = { item ->
- DMItem(
- item = item,
- onRetry = {
- state.retry(item.key)
- },
- modifier =
- Modifier
- .animateItem()
- .padding(
- horizontal = screenHorizontalPadding,
- ),
- onUserClicked = {
- toProfile.invoke(it.key)
- },
- )
- },
- )
+ loadingContent = {
+ DMLoadingItem()
+ },
+ itemContent = { item ->
+ DMItem(
+ item = item,
+ onRetry = {
+ state.retry(item.key)
+ },
+ modifier =
+ Modifier
+ .animateItem()
+ .padding(
+ horizontal = screenHorizontalPadding,
+ ),
+ onUserClicked = {
+ toProfile.invoke(it.key)
+ },
+ )
+ },
+ )
+ }
}
}
}
diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMListScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMListScreen.kt
index 164b386909..7ac5741757 100644
--- a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMListScreen.kt
+++ b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMListScreen.kt
@@ -85,28 +85,37 @@ internal fun DMListScreen(
)
},
) { contentPadding ->
- RefreshContainer(
- modifier =
- Modifier
- .fillMaxSize(),
- indicatorPadding = contentPadding,
- isRefreshing = state.isRefreshing,
- onRefresh = state::refresh,
- content = {
- LazyColumn(
- contentPadding = contentPadding,
- modifier =
- Modifier
- .padding(horizontal = screenHorizontalPadding),
- verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
- ) {
- dmList(
- data = state.items,
- onItemClicked = onItemClicked,
- )
- }
- },
- )
+ if (state.pinCodePromptVisible) {
+ DirectMessagePinCodeGate(
+ isVerifying = state.pinCodeVerifying,
+ errorMessage = state.pinCodeErrorMessage,
+ onSubmit = state::submitPinCode,
+ modifier = Modifier.padding(contentPadding),
+ )
+ } else {
+ RefreshContainer(
+ modifier =
+ Modifier
+ .fillMaxSize(),
+ indicatorPadding = contentPadding,
+ isRefreshing = state.isRefreshing,
+ onRefresh = state::refresh,
+ content = {
+ LazyColumn(
+ contentPadding = contentPadding,
+ modifier =
+ Modifier
+ .padding(horizontal = screenHorizontalPadding),
+ verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
+ ) {
+ dmList(
+ data = state.items,
+ onItemClicked = onItemClicked,
+ )
+ }
+ },
+ )
+ }
}
}
diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DirectMessagePinCodeGate.kt b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DirectMessagePinCodeGate.kt
new file mode 100644
index 0000000000..f5ef133001
--- /dev/null
+++ b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DirectMessagePinCodeGate.kt
@@ -0,0 +1,87 @@
+package dev.dimension.flare.ui.screen.dm
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedSecureTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import dev.dimension.flare.R
+
+@Composable
+internal fun DirectMessagePinCodeGate(
+ isVerifying: Boolean,
+ errorMessage: String?,
+ onSubmit: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val pinCode = rememberTextFieldState()
+ val submit by rememberUpdatedState(onSubmit)
+
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .widthIn(max = 360.dp)
+ .fillMaxWidth()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(id = R.string.dm_pin_code_title),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(
+ text = stringResource(id = R.string.dm_pin_code_message),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ OutlinedSecureTextField(
+ state = pinCode,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isVerifying,
+ label = {
+ Text(text = stringResource(id = R.string.dm_pin_code_label))
+ },
+ keyboardOptions =
+ KeyboardOptions(
+ keyboardType = KeyboardType.NumberPassword,
+ ),
+ )
+ if (errorMessage != null) {
+ Text(
+ text = errorMessage,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isVerifying && pinCode.text.isNotBlank(),
+ onClick = {
+ submit(pinCode.text.toString())
+ },
+ ) {
+ Text(text = stringResource(id = android.R.string.ok))
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index cb5846843b..1c632153a9 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -426,6 +426,9 @@
正在发送
退出对话
显示个人资料
+ 输入 XChat PIN
+ 此 XChat 身份需要先输入 PIN 才能加载私信。
+ PIN 码
%1$s 的登录已过期
正在关注
关注者
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 109dbe96a1..a086c53750 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -489,6 +489,9 @@
Sending
Leave conversation
Show profile
+ Enter XChat PIN
+ This XChat identity requires a PIN before direct messages can be loaded.
+ PIN code
Login expired for %1$s
diff --git a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml
index 09fb459770..41905c3768 100644
--- a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml
+++ b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml
@@ -408,6 +408,9 @@
发送中
退出对话
查看资料
+ 输入 XChat PIN
+ 此 XChat 身份需要先输入 PIN 才能加载私信。
+ PIN 码
启用混合时间轴标签页
将所有标签页的动态合并到一个“主页”标签页中。
添加分组
diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml
index 8effe299f6..63838725c8 100644
--- a/desktopApp/src/main/composeResources/values/strings.xml
+++ b/desktopApp/src/main/composeResources/values/strings.xml
@@ -453,6 +453,9 @@
Sending
Leave conversation
Show profile
+ Enter XChat PIN
+ This XChat identity requires a PIN before direct messages can be loaded.
+ PIN code
Enable mixed timeline tab
Combine posts from all tabs into a single Home tab.
Add group
diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DirectMessagePinCodeGate.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DirectMessagePinCodeGate.kt
new file mode 100644
index 0000000000..04c203fd2e
--- /dev/null
+++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DirectMessagePinCodeGate.kt
@@ -0,0 +1,95 @@
+package dev.dimension.flare.ui.screen.dm
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import dev.dimension.flare.Res
+import dev.dimension.flare.dm_pin_code_label
+import dev.dimension.flare.dm_pin_code_message
+import dev.dimension.flare.dm_pin_code_title
+import dev.dimension.flare.ok
+import io.github.composefluent.FluentTheme
+import io.github.composefluent.component.AccentButton
+import io.github.composefluent.component.SecureTextField
+import io.github.composefluent.component.Text
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+internal fun DirectMessagePinCodeGate(
+ isVerifying: Boolean,
+ errorMessage: String?,
+ onSubmit: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val pinCode = rememberTextFieldState()
+ val submit by rememberUpdatedState(onSubmit)
+
+ fun submitIfReady() {
+ if (!isVerifying && pinCode.text.isNotBlank()) {
+ submit(pinCode.text.toString())
+ }
+ }
+
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .widthIn(max = 360.dp)
+ .fillMaxWidth()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(Res.string.dm_pin_code_title),
+ )
+ Text(
+ text = stringResource(Res.string.dm_pin_code_message),
+ )
+ SecureTextField(
+ state = pinCode,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isVerifying,
+ header = {
+ Text(text = stringResource(Res.string.dm_pin_code_label))
+ },
+ keyboardOptions =
+ KeyboardOptions(
+ keyboardType = KeyboardType.NumberPassword,
+ ),
+ onKeyboardAction = {
+ submitIfReady()
+ },
+ )
+ if (errorMessage != null) {
+ Text(
+ text = errorMessage,
+ color = FluentTheme.colors.system.critical,
+ )
+ }
+ AccentButton(
+ onClick = ::submitIfReady,
+ modifier = Modifier.fillMaxWidth(),
+ disabled = isVerifying || pinCode.text.isBlank(),
+ ) {
+ Text(text = stringResource(Res.string.ok))
+ }
+ }
+ }
+}
diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt
index f32eb6f00f..a0eb229c5a 100644
--- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt
+++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt
@@ -79,8 +79,10 @@ fun DmConversationScreen(
)
}
val focusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) {
- focusRequester.requestFocus()
+ LaunchedEffect(state.pinCodePromptVisible) {
+ if (!state.pinCodePromptVisible) {
+ focusRequester.requestFocus()
+ }
}
val listState = rememberLazyListState()
state.items.onSuccess {
@@ -174,84 +176,96 @@ fun DmConversationScreen(
}
}
}
- FlareScrollBar(
- state = listState,
- reverseLayout = true,
- ) {
- LazyColumn(
- state = listState,
- reverseLayout = true,
- contentPadding = PaddingValues(top = 8.dp),
+ if (state.pinCodePromptVisible) {
+ DirectMessagePinCodeGate(
+ isVerifying = state.pinCodeVerifying,
+ errorMessage = state.pinCodeErrorMessage,
+ onSubmit = state::submitPinCode,
modifier =
Modifier
- .fillMaxSize(),
- verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom),
+ .weight(1f)
+ .padding(LocalWindowPadding.current),
+ )
+ } else {
+ FlareScrollBar(
+ state = listState,
+ reverseLayout = true,
) {
- stickyHeader {
- TextField(
- state = state.text,
- modifier =
- Modifier
- .padding(8.dp)
- .fillMaxWidth()
- .focusRequester(focusRequester),
- lineLimits = TextFieldLineLimits.SingleLine,
- trailing = {
- SubtleButton(
- onClick = {
- state.send()
- },
- disabled = !state.canSend,
- iconOnly = true,
- ) {
- FAIcon(
- FontAwesomeIcons.Solid.PaperPlane,
- contentDescription = stringResource(Res.string.send),
+ LazyColumn(
+ state = listState,
+ reverseLayout = true,
+ contentPadding = PaddingValues(top = 8.dp),
+ modifier =
+ Modifier
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom),
+ ) {
+ stickyHeader {
+ TextField(
+ state = state.text,
+ modifier =
+ Modifier
+ .padding(8.dp)
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ lineLimits = TextFieldLineLimits.SingleLine,
+ trailing = {
+ SubtleButton(
+ onClick = {
+ state.send()
+ },
+ disabled = !state.canSend,
+ iconOnly = true,
+ ) {
+ FAIcon(
+ FontAwesomeIcons.Solid.PaperPlane,
+ contentDescription = stringResource(Res.string.send),
+ )
+ }
+ },
+ placeholder = {
+ Text(
+ text = stringResource(Res.string.dm_send_placeholder),
)
- }
+ },
+ keyboardOptions =
+ KeyboardOptions(
+ imeAction = ImeAction.Send,
+ ),
+ onKeyboardAction = {
+ if (state.canSend) {
+ state.send()
+ }
+ },
+ )
+ }
+ items(
+ state.items,
+ key = {
+ it.id
},
- placeholder = {
- Text(
- text = stringResource(Res.string.dm_send_placeholder),
- )
+ loadingContent = {
+ DMLoadingItem()
},
- keyboardOptions =
- KeyboardOptions(
- imeAction = ImeAction.Send,
- ),
- onKeyboardAction = {
- if (state.canSend) {
- state.send()
- }
+ itemContent = { item ->
+ DMItem(
+ item = item,
+ onRetry = {
+ state.retry(item.key)
+ },
+ modifier =
+ Modifier
+ .animateItem()
+ .padding(
+ horizontal = screenHorizontalPadding,
+ ),
+ onUserClicked = {
+ toProfile.invoke(it.key)
+ },
+ )
},
)
}
- items(
- state.items,
- key = {
- it.id
- },
- loadingContent = {
- DMLoadingItem()
- },
- itemContent = { item ->
- DMItem(
- item = item,
- onRetry = {
- state.retry(item.key)
- },
- modifier =
- Modifier
- .animateItem()
- .padding(
- horizontal = screenHorizontalPadding,
- ),
- onUserClicked = {
- toProfile.invoke(it.key)
- },
- )
- },
- )
}
}
}
diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt
index bce59680c9..39d20359c5 100644
--- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt
+++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt
@@ -2,6 +2,7 @@ package dev.dimension.flare.ui.screen.dm
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@@ -40,23 +41,34 @@ internal fun DmListScreen(
RegisterTabCallback(listState, onRefresh = state::refresh)
- Box {
- FlareScrollBar(listState) {
- LazyColumn(
- contentPadding = LocalWindowPadding.current,
- modifier =
- Modifier
- .padding(horizontal = screenHorizontalPadding),
- verticalArrangement = Arrangement.spacedBy(2.dp),
- state = listState,
- ) {
- dmList(
- data = state.items,
- onItemClicked = onItemClicked,
- )
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ if (state.pinCodePromptVisible) {
+ DirectMessagePinCodeGate(
+ isVerifying = state.pinCodeVerifying,
+ errorMessage = state.pinCodeErrorMessage,
+ onSubmit = state::submitPinCode,
+ modifier = Modifier.padding(LocalWindowPadding.current),
+ )
+ } else {
+ FlareScrollBar(listState) {
+ LazyColumn(
+ contentPadding = LocalWindowPadding.current,
+ modifier =
+ Modifier
+ .padding(horizontal = screenHorizontalPadding),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ state = listState,
+ ) {
+ dmList(
+ data = state.items,
+ onItemClicked = onItemClicked,
+ )
+ }
}
}
- if (state.isRefreshing) {
+ if (!state.pinCodePromptVisible && state.isRefreshing) {
ProgressBar(
modifier =
Modifier
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 86c6f37fb4..37cbf6288e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -6,6 +6,7 @@ composemediaplayer = "0.9.0"
composewebview = "1.0.0-beta-02"
cryptographyProviderOptimal = "0.5.0"
jna = "5.19.0"
+juiceboxSdk = "0.3.6-SNAPSHOT"
lifecycleViewmodelComposeVersion = "2.10.0"
minSdk = "26"
java = "25"
@@ -79,6 +80,7 @@ composewebview = { module = "io.github.kdroidfilter:composewebview", version.ref
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptographyProviderOptimal" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
+juicebox-sdk = { module = "moe.tlaster:juicebox-sdk-kmp", version.ref = "juiceboxSdk" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
diff --git a/iosApp/flare/Localizable.xcstrings b/iosApp/flare/Localizable.xcstrings
index b5a1170090..6931ccc1eb 100644
--- a/iosApp/flare/Localizable.xcstrings
+++ b/iosApp/flare/Localizable.xcstrings
@@ -36848,6 +36848,54 @@
}
}
},
+ "dm_pin_code_label" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "PIN code"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "PIN 码"
+ }
+ }
+ }
+ },
+ "dm_pin_code_message" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "This XChat identity requires a PIN before direct messages can be loaded."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "此 XChat 身份需要先输入 PIN 才能加载私信。"
+ }
+ }
+ }
+ },
+ "dm_pin_code_title" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enter XChat PIN"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "输入 XChat PIN"
+ }
+ }
+ }
+ },
"dm_list_title" : {
"localizations" : {
"af" : {
@@ -149833,4 +149881,4 @@
}
},
"version" : "1.1"
-}
\ No newline at end of file
+}
diff --git a/iosApp/flare/UI/Screen/DMListScreen.swift b/iosApp/flare/UI/Screen/DMListScreen.swift
index d1ccd73bc1..419bbcfa00 100644
--- a/iosApp/flare/UI/Screen/DMListScreen.swift
+++ b/iosApp/flare/UI/Screen/DMListScreen.swift
@@ -4,70 +4,88 @@ import SwiftUIBackports
struct DMListScreen: View {
let accountType: AccountType
+ @State private var pinCode: String = ""
@StateObject private var presenter: KotlinPresenter
var body: some View {
- List {
- PagingView(data: presenter.state.items) { item in
- NavigationLink(value: Route.dmConversation(accountType, item.key, item.users.count == 1 ? item.users.first?.name.innerText ?? String(localized: "direct_messages_title") : String(localized: "direct_messages_title"))) {
- HStack {
- if item.hasUser, let image = item.users.first?.avatar {
- AvatarView(data: image.url, customHeader: image.customHeaders)
- .frame(width: 48, height: 48)
- } else {
- Image("fa-list")
- }
- VStack(
- alignment: .leading,
- spacing: 8
- ) {
+ Group {
+ if presenter.state.pinCodePromptVisible {
+ XChatPinCodeGate(
+ isVerifying: presenter.state.pinCodeVerifying,
+ errorMessage: presenter.state.pinCodeErrorMessage,
+ pinCode: $pinCode
+ ) { pinCode in
+ presenter.state.submitPinCode(pinCode: pinCode)
+ }
+ } else {
+ List {
+ PagingView(data: presenter.state.items) { item in
+ NavigationLink(value: Route.dmConversation(accountType, item.key, item.users.count == 1 ? item.users.first?.name.innerText ?? String(localized: "direct_messages_title") : String(localized: "direct_messages_title"))) {
HStack {
- if item.hasUser {
- ForEach(item.users, id: \.key) { user in
- RichText(text: user.name)
- .lineLimit(1)
- if (item.users.count == 1) {
- Text(user.handle.canonical)
+ if item.hasUser, let image = item.users.first?.avatar {
+ AvatarView(data: image.url, customHeader: image.customHeaders)
+ .frame(width: 48, height: 48)
+ } else {
+ Image("fa-list")
+ }
+ VStack(
+ alignment: .leading,
+ spacing: 8
+ ) {
+ HStack {
+ if item.hasUser {
+ ForEach(item.users, id: \.key) { user in
+ RichText(text: user.name)
+ .lineLimit(1)
+ if (item.users.count == 1) {
+ Text(user.handle.canonical)
+ .lineLimit(1)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ Spacer()
+ if let lasMessage = item.lastMessage {
+ DateTimeText(data: lasMessage.timestamp)
.lineLimit(1)
.font(.caption)
.foregroundStyle(.secondary)
}
}
+ Text(item.lastMessageText)
+ .lineLimit(2)
+ .font(.caption)
+ .foregroundStyle(.secondary)
}
- Spacer()
- if let lasMessage = item.lastMessage {
- DateTimeText(data: lasMessage.timestamp)
+ if item.unreadCount > 0 {
+ Spacer()
+ Text("\(item.unreadCount)")
.lineLimit(1)
.font(.caption)
- .foregroundStyle(.secondary)
+ .padding(6)
+ .background(
+ Circle()
+ .fill(Color.accentColor)
+ )
+ .foregroundStyle(.white)
}
}
- Text(item.lastMessageText)
- .lineLimit(2)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- if item.unreadCount > 0 {
- Spacer()
- Text("\(item.unreadCount)")
- .lineLimit(1)
- .font(.caption)
- .padding(6)
- .background(
- Circle()
- .fill(Color.accentColor)
- )
- .foregroundStyle(.white)
}
+ } loadingContent: {
+ UiListPlaceholder()
}
}
- } loadingContent: {
- UiListPlaceholder()
+ .refreshable {
+ try? await presenter.state.refreshSuspend()
+ }
}
}
- .refreshable {
- try? await presenter.state.refreshSuspend()
- }
.navigationTitle("dm_list_title")
+ .onChange(of: presenter.state.pinCodePromptVisible) { _, newValue in
+ if !newValue {
+ pinCode = ""
+ }
+ }
}
}
@@ -102,40 +120,59 @@ struct UserDMConversationScreen: View {
struct DMConversationScreen: View {
@State private var inputText: String = ""
+ @State private var pinCode: String = ""
@Environment(\.openURL) private var openURL
@StateObject private var presenter: KotlinPresenter
var body: some View {
- DMConversationMessagesView(
- data: presenter.state.items,
- onRetry: { key in
- presenter.state.retry(key: key)
- },
- onOpenURL: { url in
- openURL(url)
- }
- )
- .background(Color(.systemGroupedBackground))
- .safeAreaInset(edge: .bottom) {
- HStack {
- TextField("dm_conversation_input_placeholder", text: $inputText)
- .padding()
+ Group {
+ if presenter.state.pinCodePromptVisible {
+ XChatPinCodeGate(
+ isVerifying: presenter.state.pinCodeVerifying,
+ errorMessage: presenter.state.pinCodeErrorMessage,
+ pinCode: $pinCode
+ ) { pinCode in
+ presenter.state.submitPinCode(pinCode: pinCode)
+ }
+ .background(Color(.systemGroupedBackground))
+ } else {
+ DMConversationMessagesView(
+ data: presenter.state.items,
+ onRetry: { key in
+ presenter.state.retry(key: key)
+ },
+ onOpenURL: { url in
+ openURL(url)
+ }
+ )
+ .background(Color(.systemGroupedBackground))
+ .safeAreaInset(edge: .bottom) {
+ HStack {
+ TextField("dm_conversation_input_placeholder", text: $inputText)
+ .padding()
+ .backport
+ .glassEffect(.regularInteractive, in: .capsule, fallbackBackground: .regularMaterial)
+ Button(action: {
+ presenter.state.send(message: inputText)
+ inputText = ""
+ }) {
+ Image(systemName: "paperplane.fill")
+ .font(.title2)
+ }
.backport
- .glassEffect(.regularInteractive, in: .capsule, fallbackBackground: .regularMaterial)
- Button(action: {
- presenter.state.send(message: inputText)
- inputText = ""
- }) {
- Image(systemName: "paperplane.fill")
- .font(.title2)
+ .glassProminentButtonStyle()
+ .disabled(inputText.isEmpty)
}
+ .padding([.horizontal, .bottom])
.backport
- .glassProminentButtonStyle()
- .disabled(inputText.isEmpty)
+ .glassEffectContainer()
}
- .padding([.horizontal, .bottom])
- .backport
- .glassEffectContainer()
}
+ }
+ .onChange(of: presenter.state.pinCodePromptVisible) { _, newValue in
+ if !newValue {
+ pinCode = ""
+ }
+ }
}
}
@@ -144,3 +181,55 @@ extension DMConversationScreen {
self._presenter = .init(wrappedValue: .init(presenter: DMConversationPresenter(accountType: accountType, roomKey: roomKey)))
}
}
+
+private struct XChatPinCodeGate: View {
+ let isVerifying: Bool
+ let errorMessage: String?
+ @Binding var pinCode: String
+ let onSubmit: (String) -> Void
+
+ private var trimmedPinCode: String {
+ pinCode.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private func submitIfReady() {
+ let value = trimmedPinCode
+ guard !isVerifying, !value.isEmpty else { return }
+ onSubmit(value)
+ }
+
+ var body: some View {
+ VStack(spacing: 12) {
+ Text("dm_pin_code_title")
+ .font(.headline)
+ Text("dm_pin_code_message")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ SecureField("dm_pin_code_label", text: $pinCode)
+ .textContentType(.oneTimeCode)
+ .keyboardType(.numberPad)
+ .textFieldStyle(.roundedBorder)
+ .disabled(isVerifying)
+ .onSubmit(submitIfReady)
+ if let errorMessage, !errorMessage.isEmpty {
+ Text(errorMessage)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .multilineTextAlignment(.center)
+ }
+ Button(action: submitIfReady) {
+ if isVerifying {
+ ProgressView()
+ } else {
+ Text("OK")
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(isVerifying || trimmedPinCode.isEmpty)
+ }
+ .frame(maxWidth: 360)
+ .padding(24)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
+ }
+}
diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/DirectMessageDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/DirectMessageDataSource.kt
index d818a2ebdd..ba2f923eda 100644
--- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/DirectMessageDataSource.kt
+++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/DirectMessageDataSource.kt
@@ -4,6 +4,7 @@ import androidx.paging.PagingData
import dev.dimension.flare.common.CacheData
import dev.dimension.flare.data.database.cache.model.DbMessageItem
import dev.dimension.flare.data.datasource.microblog.handler.DirectMessageHandler
+import dev.dimension.flare.data.datasource.microblog.loader.DirectMessagePinCodeStatus
import dev.dimension.flare.model.MicroBlogKey
import dev.dimension.flare.model.PlatformType
import dev.dimension.flare.ui.model.ClickEvent
@@ -25,6 +26,11 @@ import kotlin.uuid.Uuid
public interface DirectMessageDataSource : AuthenticatedMicroblogDataSource {
public val directMessageHandler: DirectMessageHandler
+ public val directMessagePinCodeStatus: Flow
+ get() = directMessageHandler.pinCodeStatus
+
+ public suspend fun submitDirectMessagePinCode(pinCode: String): DirectMessagePinCodeStatus = directMessageHandler.submitPinCode(pinCode)
+
public fun directMessageList(scope: CoroutineScope): Flow> = directMessageHandler.list(scope)
public fun directMessageConversation(
diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/DirectMessageHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/DirectMessageHandler.kt
index 187cb66c97..f149db1c1f 100644
--- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/DirectMessageHandler.kt
+++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/DirectMessageHandler.kt
@@ -18,6 +18,7 @@ import dev.dimension.flare.data.database.cache.model.DbMessageRoomReference
import dev.dimension.flare.data.datasource.microblog.createSendingDirectMessage
import dev.dimension.flare.data.datasource.microblog.loader.DirectMessageDelta
import dev.dimension.flare.data.datasource.microblog.loader.DirectMessageLoader
+import dev.dimension.flare.data.datasource.microblog.loader.DirectMessagePinCodeStatus
import dev.dimension.flare.data.datasource.microblog.offsetPagingConfig
import dev.dimension.flare.data.datasource.microblog.paging.DirectMessageItemDbPageLoader
import dev.dimension.flare.data.datasource.microblog.paging.DirectMessageTimelineDbPageLoader
@@ -55,6 +56,11 @@ public class DirectMessageHandler(
private val accountType = AccountType.Specific(accountKey)
private val inMemoryBadgeCount = MutableStateFlow(null)
+ public val pinCodeStatus: Flow
+ get() = loader.pinCodeStatus
+
+ public suspend fun submitPinCode(pinCode: String): DirectMessagePinCodeStatus = loader.submitPinCode(pinCode)
+
public fun list(scope: CoroutineScope): Flow> =
Pager(
config = offsetPagingConfig,
@@ -249,7 +255,12 @@ public class DirectMessageHandler(
public val badgeCount: CacheData by lazy {
Cacheable(
fetchSource = {
- inMemoryBadgeCount.value = loader.loadBadgeCount()
+ inMemoryBadgeCount.value =
+ if (loader.pinCodeStatus.first().canLoadDirectMessage) {
+ loader.loadBadgeCount()
+ } else {
+ 0
+ }
},
cacheSource = {
inMemoryBadgeCount.filterNotNull()
@@ -428,6 +439,11 @@ public class DirectMessageHandler(
}
}
+private val DirectMessagePinCodeStatus.canLoadDirectMessage: Boolean
+ get() =
+ this == DirectMessagePinCodeStatus.NotRequired ||
+ this == DirectMessagePinCodeStatus.Verified
+
private fun UiDMItem.toDbMessageItem(roomKey: MicroBlogKey): DbMessageItem =
DbMessageItem(
messageKey = key,
diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/DirectMessageLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/DirectMessageLoader.kt
index 21bb69ad62..e58a553cf9 100644
--- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/DirectMessageLoader.kt
+++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/DirectMessageLoader.kt
@@ -18,6 +18,11 @@ public interface DirectMessageLoader {
public val runtimeTransformer: Flow
get() = flowOf(DirectMessageRuntimeTransformer())
+ public val pinCodeStatus: Flow
+ get() = flowOf(DirectMessagePinCodeStatus.NotRequired)
+
+ public suspend fun submitPinCode(pinCode: String): DirectMessagePinCodeStatus = DirectMessagePinCodeStatus.NotRequired
+
public suspend fun loadRooms(
pageSize: Int,
request: PagingRequest,
@@ -66,3 +71,18 @@ public data class DirectMessageRuntimeTransformer(
val room: (UiDMRoom) -> UiDMRoom = { it },
val item: (UiDMItem) -> UiDMItem = { it },
)
+
+@HiddenFromObjC
+public sealed interface DirectMessagePinCodeStatus {
+ public data object NotRequired : DirectMessagePinCodeStatus
+
+ public data object Required : DirectMessagePinCodeStatus
+
+ public data object Verifying : DirectMessagePinCodeStatus
+
+ public data object Verified : DirectMessagePinCodeStatus
+
+ public data class Error(
+ val message: String?,
+ ) : DirectMessagePinCodeStatus
+}
diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMConversationPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMConversationPresenter.kt
index 23bf18ba70..3ad7aba806 100644
--- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMConversationPresenter.kt
+++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMConversationPresenter.kt
@@ -5,11 +5,13 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import dev.dimension.flare.common.PagingState
import dev.dimension.flare.common.collectAsState
import dev.dimension.flare.common.toPagingState
import dev.dimension.flare.data.datasource.microblog.DirectMessageDataSource
+import dev.dimension.flare.data.datasource.microblog.loader.DirectMessagePinCodeStatus
import dev.dimension.flare.data.repository.AccountRepository
import dev.dimension.flare.data.repository.accountServiceProvider
import dev.dimension.flare.model.AccountType
@@ -17,13 +19,17 @@ import dev.dimension.flare.model.MicroBlogKey
import dev.dimension.flare.ui.model.UiDMItem
import dev.dimension.flare.ui.model.UiProfile
import dev.dimension.flare.ui.model.UiState
+import dev.dimension.flare.ui.model.collectAsUiState
import dev.dimension.flare.ui.model.flatMap
import dev.dimension.flare.ui.model.map
import dev.dimension.flare.ui.model.onSuccess
import dev.dimension.flare.ui.model.toUi
import dev.dimension.flare.ui.presenter.PresenterBase
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.time.Duration.Companion.seconds
@@ -39,31 +45,55 @@ public class DMConversationPresenter(
override fun body(): DMConversationState {
val serviceState = accountServiceProvider(accountType = accountType, repository = accountRepository)
val scope = rememberCoroutineScope()
+ val pinCodeStatus =
+ serviceState.flatMap { service ->
+ require(service is DirectMessageDataSource)
+ val status =
+ remember(service) {
+ service.directMessagePinCodeStatus
+ }.collectAsUiState(
+ initial = UiState.Loading(),
+ )
+ status.value
+ }
+ val canLoad = pinCodeStatus.canLoadDirectMessage
val items =
serviceState
.map { service ->
require(service is DirectMessageDataSource)
- remember(service, roomKey) {
- service.directMessageConversation(roomKey, scope = scope)
- }.collectAsLazyPagingItems()
+ if (canLoad) {
+ remember(service, roomKey, canLoad) {
+ service.directMessageConversation(roomKey, scope = scope)
+ }.collectAsLazyPagingItems()
+ } else {
+ remember(service, roomKey, canLoad) {
+ flowOf(PagingData.empty())
+ }.collectAsLazyPagingItems()
+ }
}.toPagingState()
val users =
- serviceState
- .flatMap { service ->
- require(service is DirectMessageDataSource)
- remember(service, roomKey) {
- service.getDirectMessageConversationInfo(roomKey)
- }.collectAsState().toUi()
- }.map {
- it.users
- }
- serviceState.onSuccess {
- require(it is DirectMessageDataSource)
- LaunchedEffect(Unit) {
- while (true) {
- delay(10.seconds)
- runCatching {
- it.fetchNewDirectMessageForConversation(roomKey)
+ if (canLoad) {
+ serviceState
+ .flatMap { service ->
+ require(service is DirectMessageDataSource)
+ remember(service, roomKey) {
+ service.getDirectMessageConversationInfo(roomKey)
+ }.collectAsState().toUi()
+ }.map {
+ it.users
+ }
+ } else {
+ UiState.Success(persistentListOf())
+ }
+ if (canLoad) {
+ serviceState.onSuccess {
+ require(it is DirectMessageDataSource)
+ LaunchedEffect(Unit) {
+ while (true) {
+ delay(10.seconds)
+ runCatching {
+ it.fetchNewDirectMessageForConversation(roomKey)
+ }
}
}
}
@@ -73,7 +103,25 @@ public class DMConversationPresenter(
override val users = users
+ override val pinCodeStatus = pinCodeStatus
+
+ override val pinCodePromptVisible = pinCodeStatus.pinCodePromptVisible
+
+ override val pinCodeVerifying = pinCodeStatus.pinCodeVerifying
+
+ override val pinCodeErrorMessage = pinCodeStatus.pinCodeErrorMessage
+
+ override fun submitPinCode(pinCode: String) {
+ serviceState.onSuccess {
+ require(it is DirectMessageDataSource)
+ scope.launch {
+ it.submitDirectMessagePinCode(pinCode)
+ }
+ }
+ }
+
override fun send(message: String) {
+ if (!canLoad) return
serviceState.onSuccess {
require(it is DirectMessageDataSource)
it.sendDirectMessage(roomKey, message)
@@ -81,6 +129,7 @@ public class DMConversationPresenter(
}
override fun retry(key: MicroBlogKey) {
+ if (!canLoad) return
serviceState.onSuccess {
require(it is DirectMessageDataSource)
it.retrySendDirectMessage(key)
@@ -101,6 +150,12 @@ public class DMConversationPresenter(
public interface DMConversationState {
public val items: PagingState
public val users: UiState>
+ public val pinCodeStatus: UiState
+ public val pinCodePromptVisible: Boolean
+ public val pinCodeVerifying: Boolean
+ public val pinCodeErrorMessage: String?
+
+ public fun submitPinCode(pinCode: String)
public fun send(message: String)
diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMListPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMListPresenter.kt
index 2236c4a22f..e264a9fc84 100644
--- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMListPresenter.kt
+++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMListPresenter.kt
@@ -4,18 +4,26 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import dev.dimension.flare.common.PagingState
import dev.dimension.flare.common.isRefreshing
import dev.dimension.flare.common.onSuccess
import dev.dimension.flare.common.toPagingState
import dev.dimension.flare.data.datasource.microblog.DirectMessageDataSource
+import dev.dimension.flare.data.datasource.microblog.loader.DirectMessagePinCodeStatus
import dev.dimension.flare.data.repository.AccountRepository
import dev.dimension.flare.data.repository.accountServiceProvider
import dev.dimension.flare.model.AccountType
import dev.dimension.flare.ui.model.UiDMRoom
+import dev.dimension.flare.ui.model.UiState
+import dev.dimension.flare.ui.model.collectAsUiState
+import dev.dimension.flare.ui.model.flatMap
import dev.dimension.flare.ui.model.map
+import dev.dimension.flare.ui.model.onSuccess
import dev.dimension.flare.ui.presenter.PresenterBase
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -29,19 +37,54 @@ public class DMListPresenter(
override fun body(): DMListState {
val scope = rememberCoroutineScope()
val serviceState = accountServiceProvider(accountType = accountType, repository = accountRepository)
+ val pinCodeStatus =
+ serviceState.flatMap { service ->
+ require(service is DirectMessageDataSource)
+ val status =
+ remember(service) {
+ service.directMessagePinCodeStatus
+ }.collectAsUiState(
+ initial = UiState.Loading(),
+ )
+ status.value
+ }
+ val canLoad = pinCodeStatus.canLoadDirectMessage
val items =
serviceState
.map { service ->
require(service is DirectMessageDataSource)
- remember(service) {
- service.directMessageList(scope = scope)
- }.collectAsLazyPagingItems()
+ if (canLoad) {
+ remember(service, canLoad) {
+ service.directMessageList(scope = scope)
+ }.collectAsLazyPagingItems()
+ } else {
+ remember(service, canLoad) {
+ flowOf(PagingData.empty())
+ }.collectAsLazyPagingItems()
+ }
}.toPagingState()
return object : DMListState {
override val items = items
+ override val pinCodeStatus = pinCodeStatus
+
+ override val pinCodePromptVisible = pinCodeStatus.pinCodePromptVisible
+
+ override val pinCodeVerifying = pinCodeStatus.pinCodeVerifying
+
+ override val pinCodeErrorMessage = pinCodeStatus.pinCodeErrorMessage
+
override val isRefreshing = items.isRefreshing
+ override fun submitPinCode(pinCode: String) {
+ serviceState.onSuccess {
+ require(it is DirectMessageDataSource)
+ scope.launch {
+ it.submitDirectMessagePinCode(pinCode)
+ }
+ }
+ }
+
override suspend fun refreshSuspend() {
items.onSuccess {
refreshSuspend()
@@ -54,7 +97,39 @@ public class DMListPresenter(
@Immutable
public interface DMListState {
public val items: PagingState
+ public val pinCodeStatus: UiState
+ public val pinCodePromptVisible: Boolean
+ public val pinCodeVerifying: Boolean
+ public val pinCodeErrorMessage: String?
public val isRefreshing: Boolean
+ public fun submitPinCode(pinCode: String)
+
public suspend fun refreshSuspend()
}
+
+internal val UiState.canLoadDirectMessage: Boolean
+ get() =
+ (this as? UiState.Success)
+ ?.data
+ ?.let {
+ it == DirectMessagePinCodeStatus.NotRequired ||
+ it == DirectMessagePinCodeStatus.Verified
+ } == true
+
+internal val UiState.pinCodePromptVisible: Boolean
+ get() =
+ (this as? UiState.Success)
+ ?.data
+ ?.let {
+ it != DirectMessagePinCodeStatus.NotRequired &&
+ it != DirectMessagePinCodeStatus.Verified
+ } == true
+
+internal val UiState.pinCodeVerifying: Boolean
+ get() = (this as? UiState.Success)?.data == DirectMessagePinCodeStatus.Verifying
+
+internal val UiState.pinCodeErrorMessage: String?
+ get() =
+ ((this as? UiState.Success)?.data as? DirectMessagePinCodeStatus.Error)
+ ?.message
diff --git a/social/xqt/build.gradle.kts b/social/xqt/build.gradle.kts
index 69fac912f3..2bbb14f586 100644
--- a/social/xqt/build.gradle.kts
+++ b/social/xqt/build.gradle.kts
@@ -51,6 +51,7 @@ kotlin {
implementation(libs.paging.common)
implementation(libs.paging.compose)
implementation(libs.cryptography.provider.optimal)
+ implementation(libs.juicebox.sdk)
}
}
val commonTest by getting {
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XChatDirectMessageMapper.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XChatDirectMessageMapper.kt
new file mode 100644
index 0000000000..cd486f1c2d
--- /dev/null
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XChatDirectMessageMapper.kt
@@ -0,0 +1,128 @@
+package dev.dimension.flare.data.datasource.xqt
+
+import dev.dimension.flare.data.network.xqt.xchat.XChatConversation
+import dev.dimension.flare.data.network.xqt.xchat.XChatConversationType
+import dev.dimension.flare.data.network.xqt.xchat.XChatDecodedEvent
+import dev.dimension.flare.data.network.xqt.xchat.XChatDecodedEventKind
+import dev.dimension.flare.data.network.xqt.xchat.XChatUser
+import dev.dimension.flare.model.MicroBlogKey
+import dev.dimension.flare.model.PlatformType
+import dev.dimension.flare.ui.model.ClickEvent
+import dev.dimension.flare.ui.model.UiDMItem
+import dev.dimension.flare.ui.model.UiDMRoom
+import dev.dimension.flare.ui.model.UiHandle
+import dev.dimension.flare.ui.model.UiProfile
+import dev.dimension.flare.ui.model.toUiImage
+import dev.dimension.flare.ui.render.toUi
+import dev.dimension.flare.ui.render.toUiPlainText
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlin.time.Instant
+
+internal fun XChatConversation.toUiDMRoom(accountKey: MicroBlogKey): UiDMRoom {
+ val usersById = participants.associate { it.userId to it.toUiProfile(accountKey.host) }
+ return UiDMRoom(
+ key = MicroBlogKey(conversationId, accountKey.host),
+ users =
+ participants
+ .mapNotNull { usersById[it.userId] }
+ .filter { it.key != accountKey }
+ .toImmutableList(),
+ lastMessage =
+ latestMessage?.toUiDMItem(
+ accountKey = accountKey,
+ users = usersById,
+ showSender = type == XChatConversationType.Group,
+ ),
+ unreadCount = unreadCount,
+ )
+}
+
+internal fun XChatDecodedEvent.toUiDMItem(
+ accountKey: MicroBlogKey,
+ users: Map,
+ showSender: Boolean,
+): UiDMItem? {
+ val senderId = senderId ?: return null
+ val itemKey = MicroBlogKey(sequenceId ?: messageId ?: return null, accountKey.host)
+ val userKey = MicroBlogKey(senderId, accountKey.host)
+ val content =
+ when (kind) {
+ XChatDecodedEventKind.Message,
+ XChatDecodedEventKind.Edit,
+ -> UiDMItem.Message.Text((text?.takeIf { it.isNotBlank() } ?: encryptedPreviewText() ?: return null).toUiPlainText())
+
+ XChatDecodedEventKind.Delete -> UiDMItem.Message.Deleted
+
+ else -> return null
+ }
+ return UiDMItem(
+ key = itemKey,
+ user = users[senderId] ?: fallbackXChatDirectMessageUser(userKey),
+ content = content,
+ timestamp = Instant.fromEpochMilliseconds(createdAtMillis ?: 0L).toUi(),
+ isFromMe = userKey == accountKey,
+ sendState = null,
+ showSender = showSender && userKey != accountKey,
+ remoteCursor = sequenceId,
+ )
+}
+
+internal fun XChatConversation.participantIds(accountId: String): List =
+ participants
+ .map { it.userId }
+ .takeIf { it.isNotEmpty() }
+ ?: conversationId
+ .split(":")
+ .takeIf { it.size == 2 }
+ .orEmpty()
+ .ifEmpty { listOf(accountId) }
+
+internal fun fallbackXChatDirectMessageUser(userKey: MicroBlogKey): UiProfile =
+ UiProfile(
+ key = userKey,
+ handle = UiHandle(raw = userKey.id, host = userKey.host),
+ avatar = null,
+ nameInternal = userKey.id.toUiPlainText(),
+ platformType = PlatformType.xQt,
+ clickEvent = ClickEvent.Noop,
+ banner = null,
+ description = null,
+ matrices =
+ UiProfile.Matrices(
+ fansCount = 0,
+ followsCount = 0,
+ statusesCount = 0,
+ platformFansCount = null,
+ ),
+ mark = persistentListOf(),
+ bottomContent = null,
+ )
+
+private fun XChatUser.toUiProfile(host: String): UiProfile =
+ UiProfile(
+ key = MicroBlogKey(userId, host),
+ handle = UiHandle(raw = screenName ?: userId, host = host),
+ avatar = avatarUrl.toUiImage(),
+ nameInternal = (name ?: screenName ?: userId).toUiPlainText(),
+ platformType = PlatformType.xQt,
+ clickEvent = ClickEvent.Noop,
+ banner = null,
+ description = null,
+ matrices =
+ UiProfile.Matrices(
+ fansCount = 0,
+ followsCount = 0,
+ statusesCount = 0,
+ platformFansCount = null,
+ ),
+ mark = persistentListOf(),
+ bottomContent = null,
+ )
+
+private fun XChatDecodedEvent.encryptedPreviewText(): String? =
+ if (encrypted || decryptError) {
+ "Encrypted message"
+ } else {
+ null
+ }
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt
index cf6da9ddd2..e473183892 100644
--- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt
@@ -99,6 +99,7 @@ private const val MAX_ASYNC_UPLOAD_SIZE = 10
internal class XQTDataSource(
override val accountKey: MicroBlogKey,
sourceCredentialFlow: Flow,
+ private val updateCredential: suspend (XQTCredential) -> Unit,
) : AuthenticatedMicroblogDataSource,
NotificationTimelineDataSource,
ComposeDataSource,
@@ -141,6 +142,7 @@ internal class XQTDataSource(
service = service,
accountKey = accountKey,
credentialFlow = credentialFlow,
+ updateCredential = updateCredential,
)
}
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDirectMessageLoader.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDirectMessageLoader.kt
index d86b7a2618..d42b161261 100644
--- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDirectMessageLoader.kt
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDirectMessageLoader.kt
@@ -1,14 +1,19 @@
package dev.dimension.flare.data.datasource.xqt
-import dev.dimension.flare.data.database.cache.mapper.XQT
import dev.dimension.flare.data.datasource.microblog.loader.DirectMessageDelta
import dev.dimension.flare.data.datasource.microblog.loader.DirectMessageLoader
+import dev.dimension.flare.data.datasource.microblog.loader.DirectMessagePinCodeStatus
import dev.dimension.flare.data.datasource.microblog.loader.DirectMessageRuntimeTransformer
import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest
import dev.dimension.flare.data.datasource.microblog.paging.PagingResult
import dev.dimension.flare.data.network.xqt.XQTService
-import dev.dimension.flare.data.network.xqt.model.AddToConversationRequest
-import dev.dimension.flare.data.network.xqt.model.PostDmNew2Request
+import dev.dimension.flare.data.network.xqt.xchat.XChatConversation
+import dev.dimension.flare.data.network.xqt.xchat.XChatDecodedEvent
+import dev.dimension.flare.data.network.xqt.xchat.XChatDecodedEventKind
+import dev.dimension.flare.data.network.xqt.xchat.XChatLoadedIdentity
+import dev.dimension.flare.data.network.xqt.xchat.XChatService
+import dev.dimension.flare.data.network.xqt.xchat.recoverIdentityWithPin
+import dev.dimension.flare.data.platform.XChatIdentityCredential
import dev.dimension.flare.data.platform.XQTCredential
import dev.dimension.flare.data.repository.tryRun
import dev.dimension.flare.model.MicroBlogKey
@@ -16,20 +21,34 @@ import dev.dimension.flare.model.PlatformType
import dev.dimension.flare.ui.model.UiDMItem
import dev.dimension.flare.ui.model.UiDMRoom
import dev.dimension.flare.ui.model.UiMedia
+import dev.dimension.flare.ui.model.UiProfile
import dev.dimension.flare.ui.model.UiState
import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
-import kotlin.uuid.Uuid
+import kotlin.time.Clock
internal class XQTDirectMessageLoader(
private val service: XQTService,
private val accountKey: MicroBlogKey,
private val credentialFlow: Flow,
+ private val updateCredential: suspend (XQTCredential) -> Unit,
) : DirectMessageLoader {
override val platformType: PlatformType = PlatformType.xQt
+ private var cachedIdentityCredential: XChatIdentityCredential? = null
+ private var cachedIdentity: XChatLoadedIdentity? = null
+ private var recoveredIdentityCredential: XChatIdentityCredential? = null
+ private var messagePullVersion: Int? = null
+ private val roomParticipants = mutableMapOf>()
+ private val roomUsers = mutableMapOf>()
+ private val pinCodeOverride = MutableStateFlow(null)
+
override val runtimeTransformer: Flow =
credentialFlow.map { credential ->
DirectMessageRuntimeTransformer(
@@ -38,49 +57,76 @@ internal class XQTDirectMessageLoader(
)
}
+ override val pinCodeStatus: Flow =
+ combine(
+ credentialFlow,
+ pinCodeOverride,
+ ) { credential, override ->
+ when {
+ !credential.requiresPinCode() -> DirectMessagePinCodeStatus.NotRequired
+ override is DirectMessagePinCodeStatus.Verifying -> override
+ override is DirectMessagePinCodeStatus.Error -> override
+ else -> DirectMessagePinCodeStatus.Required
+ }
+ }
+
+ override suspend fun submitPinCode(pinCode: String): DirectMessagePinCodeStatus {
+ val credential = credentialFlow.first()
+ val identity = credential.xchatIdentity
+ if (identity != null && identity.pinBacked != true) {
+ pinCodeOverride.value = DirectMessagePinCodeStatus.NotRequired
+ return DirectMessagePinCodeStatus.NotRequired
+ }
+ if (pinCode.isBlank()) {
+ return DirectMessagePinCodeStatus
+ .Error("PIN code is required")
+ .also { pinCodeOverride.value = it }
+ }
+ pinCodeOverride.value = DirectMessagePinCodeStatus.Verifying
+ return runCatching {
+ val recoveredIdentity =
+ if (identity == null) {
+ service.xchat.recoverIdentityWithPin(
+ userId = accountKey.id,
+ pinCode = pinCode,
+ )
+ } else {
+ service.xchat.recoverIdentityWithPin(identity, pinCode)
+ }
+ val loadedIdentity = service.xchat.loadIdentity(recoveredIdentity)
+ updateCredential(credential.copy(xchatIdentity = recoveredIdentity))
+ recoveredIdentityCredential = recoveredIdentity
+ cachedIdentityCredential = recoveredIdentity
+ cachedIdentity = loadedIdentity
+ messagePullVersion = null
+ roomParticipants.clear()
+ roomUsers.clear()
+ DirectMessagePinCodeStatus.Verified
+ }.getOrElse { throwable ->
+ DirectMessagePinCodeStatus
+ .Error(throwable.message ?: throwable::class.simpleName)
+ }.also {
+ pinCodeOverride.value = it
+ }
+ }
+
override suspend fun loadRooms(
pageSize: Int,
request: PagingRequest,
): PagingResult {
- if (request == PagingRequest.Refresh) {
- val response = service.getDMUserUpdates().inboxInitialState
- return PagingResult(
- data =
- XQT.rooms(
- accountKey = accountKey,
- propertyEntries = response?.propertyEntries,
- users = response?.users,
- conversations = response?.conversations,
- ),
- nextKey =
- if (response?.inboxTimelines?.trusted?.status == "AT_END") {
- null
- } else {
- response?.inboxTimelines?.trusted?.minEntryId
- },
- )
+ val identity = requireIdentity()
+ val nextKey = (request as? PagingRequest.Append)?.nextKey
+ if (request is PagingRequest.Append && nextKey == null) {
+ return PagingResult(endOfPaginationReached = true)
}
- val maxId =
- (request as? PagingRequest.Append)?.nextKey
- ?: return PagingResult(endOfPaginationReached = true)
- val response =
- service.getDMInboxTimelineTrusted(
- maxId = maxId,
+ val page =
+ loadInitialPage(
+ identity = identity,
+ maxLocalSequenceId = nextKey,
)
return PagingResult(
- data =
- XQT.rooms(
- accountKey = accountKey,
- propertyEntries = response.inboxTimeline?.propertyEntries,
- users = response.inboxTimeline?.users,
- conversations = response.inboxTimeline?.conversations,
- ),
- nextKey =
- if (response.inboxTimeline?.status == "AT_END") {
- null
- } else {
- response.inboxTimeline?.minEntryId
- },
+ data = page.conversations.map { rememberConversation(it) },
+ nextKey = page.nextKey,
)
}
@@ -89,85 +135,96 @@ internal class XQTDirectMessageLoader(
pageSize: Int,
request: PagingRequest,
): PagingResult {
- val response =
- service.getDMConversationTimeline(
+ val identity = requireIdentity()
+ val nextKey = (request as? PagingRequest.Append)?.nextKey
+ if (request is PagingRequest.Append && nextKey == null) {
+ return PagingResult(endOfPaginationReached = true)
+ }
+ val page =
+ service.xchat.conversationPage(
conversationId = roomKey.id,
- maxId = (request as? PagingRequest.Append)?.nextKey,
+ identity = identity,
+ before = nextKey,
)
+ val participantIds = participantIdsFor(roomKey.id, identity)
+ val users = usersFor(roomKey.id, participantIds)
+ val showSender = participantIds.size > 2
if (request == PagingRequest.Refresh) {
- service.postDMConversationMarkRead(
- conversationId = roomKey.id,
- conversationId2 = roomKey.id,
- lastReadEventId = response.conversationTimeline?.maxEntryId.orEmpty(),
- )
+ page.messages.maxSequenceId()?.let { sequenceId ->
+ tryRun {
+ service.xchat.markRead(
+ conversationId = roomKey.id,
+ sequenceId = sequenceId,
+ identity = identity,
+ participantIds = participantIds,
+ )
+ }
+ }
}
return PagingResult(
data =
- XQT.messages(
- accountKey = accountKey,
- propertyEntries = response.conversationTimeline?.propertyEntries,
- users = response.conversationTimeline?.users,
- conversations = response.conversationTimeline?.conversations,
- ),
- nextKey =
- if (response.conversationTimeline?.status == "AT_END") {
- null
- } else {
- response.conversationTimeline?.minEntryId
- },
+ page.messages
+ .mapNotNull {
+ it.toUiDMItem(
+ accountKey = accountKey,
+ users = users,
+ showSender = showSender,
+ )
+ },
+ nextKey = if (page.hasMore) page.nextKey else null,
)
}
override suspend fun fetchRoomInfo(roomKey: MicroBlogKey): UiDMRoom {
- val response =
- service.getDMConversationTimeline(
- conversationId = roomKey.id,
- context = "FETCH_DM_CONVERSATION",
- maxId = "0",
- )
- return XQT
- .rooms(
- accountKey = accountKey,
- propertyEntries = response.conversationTimeline?.propertyEntries,
- users = response.conversationTimeline?.users,
- conversations = response.conversationTimeline?.conversations,
- ).single()
+ val identity = requireIdentity()
+ val conversation =
+ loadInitialPage(identity = identity)
+ .conversations
+ .firstOrNull { it.conversationId == roomKey.id }
+ return conversation
+ ?.let { rememberConversation(it) }
+ ?: fallbackRoom(roomKey.id, participantIdsFor(roomKey.id, identity))
}
override suspend fun sendMessage(
roomKey: MicroBlogKey,
message: String,
): UiDMItem {
- val response =
- service.getDMConversationTimeline(
+ val identity = requireIdentity()
+ val participantIds = participantIdsFor(roomKey.id, identity)
+ val sendResult =
+ service.xchat.sendText(
conversationId = roomKey.id,
- context = "FETCH_DM_CONVERSATION",
- maxId = "0",
+ text = message,
+ identity = identity,
+ participantIds = participantIds,
)
- val sendResponse =
- service.postDmNew2(
- PostDmNew2Request(
+ val users = usersFor(roomKey.id, participantIds)
+ return (
+ sendResult.decodedEvent
+ ?: XChatDecodedEvent(
+ sequenceId = sendResult.sequenceId,
+ messageId = sendResult.messageId,
+ senderId = accountKey.id,
conversationId = roomKey.id,
- requestId = Uuid.random().toString(),
+ createdAtMillis = Clock.System.now().toEpochMilliseconds(),
+ kind = XChatDecodedEventKind.Message,
text = message,
- ),
- )
- return XQT
- .messages(
- accountKey = accountKey,
- propertyEntries = sendResponse.propertyEntries,
- users = sendResponse.users,
- conversations = response.conversationTimeline?.conversations,
- ).single()
+ )
+ ).toUiDMItem(
+ accountKey = accountKey,
+ users = users,
+ showSender = participantIds.size > 2,
+ ) ?: error("XChat send response did not contain a renderable message")
}
override suspend fun deleteMessage(
roomKey: MicroBlogKey,
messageKey: MicroBlogKey,
) {
- service.postDMMessageDeleteMutation(
- messageId = messageKey.id,
- requestId = Uuid.random().toString(),
+ service.xchat.deleteMessages(
+ conversationId = roomKey.id,
+ sequenceIds = listOf(messageKey.id),
)
}
@@ -175,99 +232,210 @@ internal class XQTDirectMessageLoader(
roomKey: MicroBlogKey,
cursor: String?,
): DirectMessageDelta {
- val response = service.getDMConversationTimeline(conversationId = roomKey.id)
- service.postDMConversationMarkRead(
- conversationId = roomKey.id,
- conversationId2 = roomKey.id,
- lastReadEventId = response.conversationTimeline?.maxEntryId.orEmpty(),
- )
+ val identity = requireIdentity()
+ val page =
+ service.xchat.conversationPage(
+ conversationId = roomKey.id,
+ identity = identity,
+ )
+ val participantIds = participantIdsFor(roomKey.id, identity)
+ val users = usersFor(roomKey.id, participantIds)
+ val newEvents = page.messages.filter { it.isAfter(cursor) }
+ newEvents.maxSequenceId()?.let { sequenceId ->
+ tryRun {
+ service.xchat.markRead(
+ conversationId = roomKey.id,
+ sequenceId = sequenceId,
+ identity = identity,
+ participantIds = participantIds,
+ )
+ }
+ }
return DirectMessageDelta(
messages =
- XQT.messages(
- accountKey = accountKey,
- propertyEntries = response.conversationTimeline?.propertyEntries,
- users = response.conversationTimeline?.users,
- conversations = response.conversationTimeline?.conversations,
- ),
+ newEvents.mapNotNull {
+ it.toUiDMItem(
+ accountKey = accountKey,
+ users = users,
+ showSender = participantIds.size > 2,
+ )
+ },
+ deletedMessageKeys =
+ newEvents
+ .filter { it.kind == XChatDecodedEventKind.Delete }
+ .flatMap { it.targetSequenceIds }
+ .map { MicroBlogKey(it, accountKey.host) },
)
}
override suspend fun leaveRoom(roomKey: MicroBlogKey) {
- service.postDMConversationDelete(conversationId = roomKey.id)
+ roomParticipants.remove(roomKey.id)
+ roomUsers.remove(roomKey.id)
}
override fun createRoom(userKey: MicroBlogKey): Flow> =
flow {
- val accountIdLong =
- accountKey.id.toLongOrNull()
- ?: throw Exception("Invalid account key")
- val userIdLong =
- userKey.id.toLongOrNull()
- ?: throw Exception("Invalid user key")
- val roomId =
- listOf(
- accountIdLong,
- userIdLong,
- ).sortedBy { it }
- .joinToString("-")
- tryRun {
- val response =
- service.getDMConversationTimeline(
- conversationId = roomId,
- )
- if (response.conversationTimeline?.propertyEntries.isNullOrEmpty()) {
- service
- .postDMWelcomeMessagesAddToConversation(
- requestId = Uuid.random().toString(),
- body =
- AddToConversationRequest(
- conversationId = roomId,
- ),
- )
- service.getDMConversationTimeline(
- conversationId = roomId,
- )
- } else {
- response
- }
- }.fold(
- onSuccess = { response ->
- emit(
- UiState.Success(
- XQT
- .rooms(
- accountKey = accountKey,
- propertyEntries = response.conversationTimeline?.propertyEntries,
- users = response.conversationTimeline?.users,
- conversations = response.conversationTimeline?.conversations,
- ).single(),
- ),
- )
- },
- onFailure = {
- emit(UiState.Error(it))
- },
+ val roomId = XChatService.conversationId1on1(accountKey.id, userKey.id)
+ val participantIds = listOf(accountKey.id, userKey.id)
+ roomParticipants[roomId] = participantIds
+ roomUsers[roomId] = usersFor(roomId, participantIds)
+ emit(
+ UiState.Success(
+ UiDMRoom(
+ key = MicroBlogKey(roomId, accountKey.host),
+ users = listOf(fallbackXChatDirectMessageUser(userKey)).toImmutableList(),
+ lastMessage = null,
+ unreadCount = 0,
+ ),
+ ),
)
}
override suspend fun canSend(userKey: MicroBlogKey): Boolean =
tryRun {
- val canDm =
- service
- .getDMPermissions(userKey.id)
- .body()
- ?.permissions
- ?.idKeys
- ?.get(userKey.id)
- ?.canDm == true
- if (!canDm) {
- throw Exception("Cannot send DM")
+ requireIdentity()
+ val canMessage = service.xchat.canMessage(userKey.id)
+ if (!canMessage) {
+ throw Exception("Cannot send XChat message")
}
}.isSuccess
- override suspend fun loadBadgeCount(): Int = service.getBadgeCount().dmUnreadCount?.toInt() ?: 0
+ override suspend fun loadBadgeCount(): Int =
+ tryRun {
+ loadInitialPage(identity = requireIdentity())
+ .conversations
+ .sumOf { it.unreadCount }
+ .toInt()
+ }.getOrDefault(0)
+
+ private suspend fun requireIdentity(): XChatLoadedIdentity {
+ val credential = credentialFlow.first()
+ val identityCredential =
+ credential.xchatIdentity
+ ?: recoveredIdentityCredential
+ ?.takeIf { it.userId == accountKey.id && it.hasPrivateKeys }
+ ?: throw IllegalStateException("XChat PIN code is required")
+ val effectiveIdentityCredential =
+ if (identityCredential.requiresRecoveredPrivateKey()) {
+ recoveredIdentityCredential
+ ?.takeIf { it.pinLockKey == identityCredential.pinLockKey && it.hasPrivateKeys }
+ ?: throw IllegalStateException("XChat PIN code is required")
+ } else {
+ identityCredential
+ }
+ if (effectiveIdentityCredential != cachedIdentityCredential) {
+ cachedIdentityCredential = effectiveIdentityCredential
+ cachedIdentity = service.xchat.loadIdentity(effectiveIdentityCredential)
+ messagePullVersion = null
+ roomParticipants.clear()
+ roomUsers.clear()
+ }
+ return cachedIdentity ?: error("XChat identity cache was not initialized")
+ }
+
+ private fun rememberConversation(conversation: XChatConversation): UiDMRoom {
+ val room = conversation.toUiDMRoom(accountKey)
+ val participantIds = conversation.participantIds(accountKey.id)
+ roomParticipants[conversation.conversationId] = participantIds
+ roomUsers[conversation.conversationId] =
+ (
+ room.users +
+ participantIds.map {
+ fallbackXChatDirectMessageUser(MicroBlogKey(it, accountKey.host))
+ }
+ ).distinctBy { it.key.id }
+ .associateBy { it.key.id }
+ return room
+ }
+
+ private suspend fun participantIdsFor(
+ conversationId: String,
+ identity: XChatLoadedIdentity,
+ ): List =
+ roomParticipants[conversationId]
+ ?: conversationId
+ .split(":")
+ .takeIf { it.size == 2 }
+ ?: loadInitialPage(identity = identity)
+ .conversations
+ .firstOrNull { it.conversationId == conversationId }
+ ?.let { conversation ->
+ rememberConversation(conversation)
+ conversation.participantIds(accountKey.id)
+ }
+ ?: throw IllegalStateException("XChat conversation participants are not available")
+
+ private fun usersFor(
+ conversationId: String,
+ participantIds: List,
+ ): Map =
+ roomUsers[conversationId]
+ ?: participantIds
+ .associateWith { fallbackXChatDirectMessageUser(MicroBlogKey(it, accountKey.host)) }
+ .also { roomUsers[conversationId] = it }
+
+ private suspend fun loadInitialPage(
+ identity: XChatLoadedIdentity,
+ maxLocalSequenceId: String? = null,
+ ) = service
+ .xchat
+ .initialPage(
+ identity = identity,
+ maxLocalSequenceId = maxLocalSequenceId,
+ messagePullVersion = messagePullVersion,
+ ).also { page ->
+ messagePullVersion = page.messagePullVersion ?: messagePullVersion
+ }
+
+ private fun fallbackRoom(
+ conversationId: String,
+ participantIds: List,
+ ): UiDMRoom =
+ UiDMRoom(
+ key = MicroBlogKey(conversationId, accountKey.host),
+ users =
+ participantIds
+ .filterNot { it == accountKey.id }
+ .map { fallbackXChatDirectMessageUser(MicroBlogKey(it, accountKey.host)) }
+ .toImmutableList(),
+ lastMessage = null,
+ unreadCount = 0,
+ )
+
+ private fun XQTCredential.requiresPinCode(): Boolean {
+ val identity =
+ xchatIdentity
+ ?: return recoveredIdentityCredential
+ ?.takeIf { it.userId == accountKey.id && it.hasPrivateKeys } == null
+ return identity.requiresRecoveredPrivateKey() &&
+ recoveredIdentityCredential?.pinLockKey != identity.pinLockKey
+ }
+}
+
+private fun List.maxSequenceId(): String? =
+ mapNotNull { it.sequenceId?.toULongOrNull() }
+ .maxOrNull()
+ ?.toString()
+
+private fun XChatDecodedEvent.isAfter(cursor: String?): Boolean {
+ val sequenceId = sequenceId ?: return cursor == null
+ val current = sequenceId.toULongOrNull()
+ val previous = cursor?.toULongOrNull()
+ return when {
+ cursor == null -> true
+ current != null && previous != null -> current > previous
+ else -> sequenceId > cursor
+ }
}
+private val XChatIdentityCredential.pinLockKey: String
+ get() = "$userId:$version"
+
+private val XChatIdentityCredential.hasPrivateKeys: Boolean
+ get() = identityPrivateJwk != null && signingPrivateJwk != null
+
+private fun XChatIdentityCredential.requiresRecoveredPrivateKey(): Boolean = pinBacked == true && !hasPrivateKeys
+
private fun UiDMRoom.withXqtMediaAuth(
credential: XQTCredential,
host: String,
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTService.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTService.kt
index 2d06093b45..cbe93358ff 100644
--- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTService.kt
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTService.kt
@@ -34,6 +34,9 @@ import dev.dimension.flare.data.network.xqt.api.createV11PostApi
import dev.dimension.flare.data.network.xqt.api.createV20GetApi
import dev.dimension.flare.data.network.xqt.api.createVDmPostJsonPostApi
import dev.dimension.flare.data.network.xqt.elonmusk114514.ElonMusk1145141919810
+import dev.dimension.flare.data.network.xqt.emusks.EmusksRawClient
+import dev.dimension.flare.data.network.xqt.emusks.EmusksTransactionIdProvider
+import dev.dimension.flare.data.network.xqt.xchat.XChatService
import dev.dimension.flare.data.repository.LoginExpiredException
import dev.dimension.flare.model.MicroBlogKey
import dev.dimension.flare.model.PlatformType
@@ -140,6 +143,34 @@ internal class XQTService(
accountKey = accountKey,
chocolateFlow = chocolateFlow,
).createVDmPostJsonPostApi() {
+ private val emusks by lazy {
+ EmusksRawClient(
+ cookieProvider = { chocolateFlow?.firstOrNull() },
+ transactionIdProvider =
+ EmusksTransactionIdProvider { method, path ->
+ runCatching {
+ ElonMusk1145141919810.senpaiSukissu(
+ method = method,
+ path = path,
+ )
+ }.getOrDefault("")
+ },
+ loginExpiredException =
+ accountKey?.let { key ->
+ {
+ LoginExpiredException(
+ key,
+ PlatformType.xQt,
+ )
+ }
+ },
+ )
+ }
+
+ internal val xchat: XChatService by lazy {
+ XChatService(emusks)
+ }
+
companion object {
fun checkChocolate(value: String) =
// value.contains("gt=") &&
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksClientProfile.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksClientProfile.kt
new file mode 100644
index 0000000000..7bcfb002f5
--- /dev/null
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksClientProfile.kt
@@ -0,0 +1,49 @@
+package dev.dimension.flare.data.network.xqt.emusks
+
+internal data class EmusksTlsFingerprint(
+ val ja3: String,
+ val ja4r: String,
+ val userAgent: String,
+)
+
+internal data class EmusksClientProfile(
+ val bearer: String,
+ val fingerprint: EmusksTlsFingerprint,
+ val defaultHeaders: Map,
+)
+
+internal object EmusksClients {
+ private val chromeFingerprint =
+ EmusksTlsFingerprint(
+ userAgent =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
+ ja3 =
+ "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53," +
+ "35-5-27-16-0-10-13-23-45-65037-17613-18-65281-51-43-11,4588-29-23-24,0",
+ ja4r =
+ "t13d1516h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030," +
+ "cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,fe0d," +
+ "ff01_0403,0804,0401,0503,0805,0501,0806,0601",
+ )
+
+ val Web: EmusksClientProfile =
+ EmusksClientProfile(
+ bearer =
+ "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D" +
+ "1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA",
+ fingerprint = chromeFingerprint,
+ defaultHeaders =
+ mapOf(
+ "accept-language" to "en-US,en;q=0.9",
+ "priority" to "u=1, i",
+ "sec-ch-ua" to "\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"",
+ "sec-ch-ua-mobile" to "?0",
+ "sec-ch-ua-platform" to "\"macOS\"",
+ "sec-fetch-dest" to "empty",
+ "sec-fetch-mode" to "cors",
+ "sec-fetch-site" to "same-origin",
+ "sec-gpc" to "1",
+ ),
+ )
+}
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksCookie.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksCookie.kt
new file mode 100644
index 0000000000..0a5bd2841a
--- /dev/null
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksCookie.kt
@@ -0,0 +1,75 @@
+package dev.dimension.flare.data.network.xqt.emusks
+
+internal data class EmusksCookie(
+ val values: Map,
+) {
+ val authToken: String?
+ get() = values[AUTH_TOKEN]
+
+ val csrfToken: String?
+ get() = values[CSRF_TOKEN]
+
+ val guestToken: String?
+ get() = values[GUEST_TOKEN]
+
+ val hasAuthenticatedSession: Boolean
+ get() = authToken != null && csrfToken != null
+
+ fun toHeader(): String = values.entries.joinToString("; ") { (name, value) -> "$name=$value" }
+
+ fun plus(other: EmusksCookie): EmusksCookie =
+ EmusksCookie(
+ values = values + other.values,
+ )
+
+ companion object {
+ private const val AUTH_TOKEN = "auth_token"
+ private const val CSRF_TOKEN = "ct0"
+ private const val GUEST_TOKEN = "gt"
+
+ fun parse(cookieHeader: String): EmusksCookie =
+ EmusksCookie(
+ values =
+ cookieHeader
+ .split(';')
+ .mapNotNull { part ->
+ val index = part.indexOf('=')
+ if (index <= 0) {
+ return@mapNotNull null
+ }
+ val name = part.substring(0, index).trim()
+ val value = part.substring(index + 1).trim()
+ if (name.isEmpty()) {
+ null
+ } else {
+ name to value
+ }
+ }.toMap(),
+ )
+
+ fun fromAuthToken(authToken: String): EmusksCookie =
+ EmusksCookie(
+ values = mapOf(AUTH_TOKEN to authToken),
+ )
+
+ fun fromSetCookieHeaders(setCookieHeaders: List): EmusksCookie =
+ EmusksCookie(
+ values =
+ setCookieHeaders
+ .mapNotNull { header ->
+ val cookiePair = header.substringBefore(';')
+ val index = cookiePair.indexOf('=')
+ if (index <= 0) {
+ return@mapNotNull null
+ }
+ val name = cookiePair.substring(0, index).trim()
+ val value = cookiePair.substring(index + 1).trim()
+ if (name.isEmpty()) {
+ null
+ } else {
+ name to value
+ }
+ }.toMap(),
+ )
+ }
+}
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksOperations.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksOperations.kt
new file mode 100644
index 0000000000..19f70b80c0
--- /dev/null
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksOperations.kt
@@ -0,0 +1,76 @@
+package dev.dimension.flare.data.network.xqt.emusks
+
+internal enum class EmusksHttpMethod {
+ GET,
+ POST,
+ PUT,
+ DELETE,
+}
+
+internal data class EmusksGraphqlOperation(
+ val method: EmusksHttpMethod,
+ val queryId: String,
+)
+
+internal data class EmusksRestOperation(
+ val method: EmusksHttpMethod,
+ val url: String,
+)
+
+internal object EmusksGraphqlOperations {
+ private val operations =
+ mapOf(
+ "AddParticipantsMutation" to EmusksGraphqlOperation(EmusksHttpMethod.POST, "oBwyQ0_xVbAQ8FAyG0pCRA"),
+ "DMConversationSearchTabGroupsQuery" to
+ EmusksGraphqlOperation(EmusksHttpMethod.GET, "8D8KoSq5q9d5Su3emu2dwg"),
+ "DMConversationSearchTabPeopleQuery" to
+ EmusksGraphqlOperation(EmusksHttpMethod.GET, "qno3lU4_eSHtSFoWQUhEag"),
+ "DMMessageDeleteMutation" to EmusksGraphqlOperation(EmusksHttpMethod.POST, "BJ6DtxA2llfjnRoRjaiIiw"),
+ "DMMessageSearchTabQuery" to EmusksGraphqlOperation(EmusksHttpMethod.GET, "QUobOGFxSYwNxfh2zCpVGA"),
+ "DMPinnedInboxAppend_Mutation" to
+ EmusksGraphqlOperation(EmusksHttpMethod.POST, "o0aymgGiJY-53Y52YSUGVA"),
+ "DMPinnedInboxDelete_Mutation" to
+ EmusksGraphqlOperation(EmusksHttpMethod.POST, "_TQxP2Rb0expwVP9ktGrTQ"),
+ "DMPinnedInboxQuery" to EmusksGraphqlOperation(EmusksHttpMethod.GET, "_gBQBgClVuMQb8efxWkbbQ"),
+ "DmAllSearchSlice" to EmusksGraphqlOperation(EmusksHttpMethod.GET, "nIz5WMsrpV7s0uDu-gfOVw"),
+ "dmBlockUser" to EmusksGraphqlOperation(EmusksHttpMethod.POST, "IYw9u1KEhrS-t-BXsau4Uw"),
+ "dmUnblockUser" to EmusksGraphqlOperation(EmusksHttpMethod.POST, "Krbs6Nak_o7liWQwfV1jOQ"),
+ "useDMReactionMutationAddMutation" to
+ EmusksGraphqlOperation(EmusksHttpMethod.POST, "VyDyV9pC2oZEj6g52hgnhA"),
+ "useDMReactionMutationRemoveMutation" to
+ EmusksGraphqlOperation(EmusksHttpMethod.POST, "bV_Nim3RYHsaJwMkTXJ6ew"),
+ )
+
+ operator fun get(name: String): EmusksGraphqlOperation? = operations[name]
+}
+
+internal object EmusksV11Operations {
+ private val operations =
+ mapOf(
+ "dm/conversation" to
+ EmusksRestOperation(EmusksHttpMethod.GET, "https://api.x.com/1.1/dm/conversation.json"),
+ "dm/inbox_initial_state" to
+ EmusksRestOperation(EmusksHttpMethod.GET, "https://api.x.com/1.1/dm/inbox_initial_state.json"),
+ "dm/new2" to
+ EmusksRestOperation(EmusksHttpMethod.POST, "https://api.x.com/1.1/dm/new2.json"),
+ "dm/permissions" to
+ EmusksRestOperation(EmusksHttpMethod.GET, "https://api.x.com/1.1/dm/permissions.json"),
+ "dm/update_last_seen_event_id" to
+ EmusksRestOperation(EmusksHttpMethod.POST, "https://api.x.com/1.1/dm/update_last_seen_event_id.json"),
+ "dm/user_updates" to
+ EmusksRestOperation(EmusksHttpMethod.GET, "https://api.x.com/1.1/dm/user_updates.json"),
+ "dm/welcome_messages/add_to_conversation" to
+ EmusksRestOperation(
+ EmusksHttpMethod.POST,
+ "https://api.x.com/1.1/dm/welcome_messages/add_to_conversation.json",
+ ),
+ )
+
+ operator fun get(name: String): EmusksRestOperation? = operations[name]
+}
+
+internal object EmusksV2Operations {
+ private val operations = emptyMap()
+
+ operator fun get(name: String): EmusksRestOperation? = operations[name]
+}
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksRawClient.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksRawClient.kt
new file mode 100644
index 0000000000..0071e4a9bb
--- /dev/null
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksRawClient.kt
@@ -0,0 +1,676 @@
+package dev.dimension.flare.data.network.xqt.emusks
+
+import dev.dimension.flare.common.JSON_WITH_ENCODE_DEFAULT
+import dev.dimension.flare.data.network.ktorClient
+import de.jensklingenberg.ktorfit.Response
+import io.ktor.client.HttpClient
+import io.ktor.client.request.accept
+import io.ktor.client.request.forms.FormDataContent
+import io.ktor.client.request.get
+import io.ktor.client.request.headers
+import io.ktor.client.request.request
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.Parameters
+import io.ktor.http.URLBuilder
+import io.ktor.http.Url
+import io.ktor.http.contentType
+import io.ktor.http.isSuccess
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
+import kotlinx.serialization.json.put
+
+internal fun interface EmusksTransactionIdProvider {
+ suspend fun generate(
+ method: String,
+ path: String,
+ ): String
+}
+
+internal enum class EmusksGraphqlEndpoint(
+ val base: String,
+ val referrer: String,
+ val secFetchSite: String,
+) {
+ Main(
+ base = "https://api.x.com/graphql",
+ referrer = "https://x.com/",
+ secFetchSite = "same-site",
+ ),
+ MainTwitter(
+ base = "https://api.twitter.com/graphql",
+ referrer = "https://twitter.com/",
+ secFetchSite = "same-site",
+ ),
+ Web(
+ base = "https://x.com/i/api/graphql",
+ referrer = "https://x.com/",
+ secFetchSite = "same-origin",
+ ),
+ WebTwitter(
+ base = "https://twitter.com/i/api/graphql",
+ referrer = "https://twitter.com/",
+ secFetchSite = "same-origin",
+ ),
+}
+
+internal class EmusksApiException(
+ message: String,
+) : Exception(message)
+
+internal data class EmusksLoginResult(
+ val cookies: EmusksCookie,
+ val userId: String?,
+)
+
+private data class EmusksJsonResponse(
+ val response: HttpResponse,
+ val text: String,
+ val json: JsonObject,
+)
+
+private data class EmusksTextResponse(
+ val response: HttpResponse,
+ val text: String,
+)
+
+internal class EmusksRawClient(
+ private val cookieProvider: suspend () -> String?,
+ private val clientProfile: EmusksClientProfile = EmusksClients.Web,
+ private val graphqlEndpoint: EmusksGraphqlEndpoint = EmusksGraphqlEndpoint.Web,
+ private val transactionIdProvider: EmusksTransactionIdProvider? = null,
+ private val actingAsUserId: String? = null,
+ private val loginExpiredException: (() -> Exception)? = null,
+ private val client: HttpClient = ktorClient(),
+) {
+ suspend fun login(authToken: String): EmusksLoginResult {
+ if (authToken.length !in 20..50) {
+ throw EmusksApiException("invalid auth token length")
+ }
+ val response =
+ client.get("https://x.com/") {
+ accept(ContentType.Text.Html)
+ headers {
+ append(HttpHeaders.Cookie, EmusksCookie.fromAuthToken(authToken).toHeader())
+ emusksBrowserHeaders(secFetchSite = "none")
+ append(HttpHeaders.Referrer, "https://x.com/")
+ append(HttpHeaders.UserAgent, clientProfile.fingerprint.userAgent)
+ }
+ }
+ val setCookies = response.headers.getAll(HttpHeaders.SetCookie).orEmpty()
+ val cookies =
+ EmusksCookie
+ .fromAuthToken(authToken)
+ .plus(EmusksCookie.fromSetCookieHeaders(setCookies))
+ if (cookies.csrfToken == null) {
+ throw EmusksApiException("failed to log in")
+ }
+ val html = response.bodyAsText()
+ return EmusksLoginResult(
+ cookies = cookies,
+ userId = extractInitialUserId(html),
+ )
+ }
+
+ suspend fun graphqlJson(
+ queryName: String,
+ variables: JsonObject? = null,
+ features: JsonObject? = null,
+ fieldToggles: JsonObject? = null,
+ body: JsonObject? = null,
+ headers: Map = emptyMap(),
+ ): JsonObject =
+ graphqlRaw(
+ queryName = queryName,
+ variables = variables,
+ features = features,
+ fieldToggles = fieldToggles,
+ body = body,
+ headers = headers,
+ ).json
+
+ private suspend fun graphqlRaw(
+ queryName: String,
+ variables: JsonObject? = null,
+ features: JsonObject? = null,
+ fieldToggles: JsonObject? = null,
+ body: JsonObject? = null,
+ headers: Map = emptyMap(),
+ ): EmusksJsonResponse {
+ val operation =
+ EmusksGraphqlOperations[queryName]
+ ?: throw EmusksApiException("graphql query $queryName not found")
+ val isPost = operation.method == EmusksHttpMethod.POST
+ val urlBuilder = URLBuilder("${graphqlEndpoint.base}/${operation.queryId}/$queryName")
+ var requestBody: JsonObject? = null
+
+ if (isPost) {
+ requestBody =
+ buildJsonObject {
+ body?.forEach { (key, value) -> put(key, value) }
+ put("variables", mergeVariables(variables, body?.get("variables") as? JsonObject))
+ put("queryId", operation.queryId)
+ if (features != null && features.isNotEmpty()) {
+ put("features", features)
+ }
+ if (fieldToggles != null && fieldToggles.isNotEmpty()) {
+ put("fieldToggles", fieldToggles)
+ }
+ }
+ } else {
+ variables?.takeIf { it.isNotEmpty() }?.let {
+ urlBuilder.parameters.append(
+ "variables",
+ JSON_WITH_ENCODE_DEFAULT.encodeToString(JsonObject.serializer(), it),
+ )
+ }
+ features?.takeIf { it.isNotEmpty() }?.let {
+ urlBuilder.parameters.append(
+ "features",
+ JSON_WITH_ENCODE_DEFAULT.encodeToString(JsonObject.serializer(), it),
+ )
+ }
+ fieldToggles?.takeIf { it.isNotEmpty() }?.let {
+ urlBuilder.parameters.append(
+ "fieldToggles",
+ JSON_WITH_ENCODE_DEFAULT.encodeToString(JsonObject.serializer(), it),
+ )
+ }
+ }
+
+ return requestJsonResponse(
+ method = operation.method,
+ url = urlBuilder.buildString(),
+ referrer = graphqlEndpoint.referrer,
+ secFetchSite = graphqlEndpoint.secFetchSite,
+ body = requestBody,
+ extraHeaders = headers,
+ )
+ }
+
+ suspend fun graphql(
+ serializer: KSerializer,
+ queryName: String,
+ variables: JsonObject? = null,
+ features: JsonObject? = null,
+ fieldToggles: JsonObject? = null,
+ body: JsonObject? = null,
+ headers: Map = emptyMap(),
+ ): T =
+ JSON_WITH_ENCODE_DEFAULT.decodeFromString(
+ serializer,
+ JSON_WITH_ENCODE_DEFAULT.encodeToString(
+ JsonObject.serializer(),
+ graphqlJson(
+ queryName = queryName,
+ variables = variables,
+ features = features,
+ fieldToggles = fieldToggles,
+ body = body,
+ headers = headers,
+ ),
+ ),
+ )
+
+ suspend fun apolloGraphqlJson(
+ operationId: String,
+ operationName: String,
+ query: String,
+ variables: JsonObject = JsonObject(emptyMap()),
+ ): JsonObject =
+ json(
+ JsonObject.serializer(),
+ method = EmusksHttpMethod.POST,
+ url = "https://api.x.com/graphql/$operationId/$operationName",
+ body =
+ buildJsonObject {
+ put("operationName", operationName)
+ put("variables", variables)
+ put("query", query)
+ put("queryId", operationId)
+ },
+ headers =
+ mapOf(
+ "Origin" to "https://chat.x.com",
+ "x-apollo-operation-id" to operationId,
+ "x-apollo-operation-name" to operationName,
+ "apollo-require-preflight" to "true",
+ ),
+ referrer = "https://chat.x.com/",
+ secFetchSite = "same-site",
+ )
+
+ suspend fun graphqlResponse(
+ serializer: KSerializer,
+ queryName: String,
+ variables: JsonObject? = null,
+ features: JsonObject? = null,
+ fieldToggles: JsonObject? = null,
+ body: JsonObject? = null,
+ headers: Map = emptyMap(),
+ ): Response =
+ graphqlRaw(
+ queryName = queryName,
+ variables = variables,
+ features = features,
+ fieldToggles = fieldToggles,
+ body = body,
+ headers = headers,
+ ).toResponse(serializer)
+
+ suspend fun json(
+ serializer: KSerializer,
+ method: EmusksHttpMethod,
+ url: String,
+ params: Map = emptyMap(),
+ body: Any? = null,
+ headers: Map = emptyMap(),
+ referrer: String = "https://x.com/",
+ secFetchSite: String = "same-origin",
+ ): T =
+ JSON_WITH_ENCODE_DEFAULT.decodeFromString(
+ serializer,
+ jsonRaw(
+ method = method,
+ url = url,
+ params = params,
+ body = body,
+ headers = headers,
+ referrer = referrer,
+ secFetchSite = secFetchSite,
+ ).text,
+ )
+
+ suspend fun jsonResponse(
+ serializer: KSerializer,
+ method: EmusksHttpMethod,
+ url: String,
+ params: Map = emptyMap(),
+ body: Any? = null,
+ headers: Map = emptyMap(),
+ referrer: String = "https://x.com/",
+ secFetchSite: String = "same-origin",
+ ): Response =
+ jsonRaw(
+ method = method,
+ url = url,
+ params = params,
+ body = body,
+ headers = headers,
+ referrer = referrer,
+ secFetchSite = secFetchSite,
+ ).toResponse(serializer)
+
+ suspend fun unitResponse(
+ method: EmusksHttpMethod,
+ url: String,
+ params: Map = emptyMap(),
+ body: Any? = null,
+ headers: Map = emptyMap(),
+ referrer: String = "https://x.com/",
+ secFetchSite: String = "same-origin",
+ ): Response =
+ textRaw(
+ method = method,
+ url = url,
+ params = params,
+ body = body,
+ headers = headers,
+ referrer = referrer,
+ secFetchSite = secFetchSite,
+ ).toUnitResponse()
+
+ suspend fun text(
+ method: EmusksHttpMethod,
+ url: String,
+ params: Map = emptyMap(),
+ body: Any? = null,
+ headers: Map = emptyMap(),
+ referrer: String = "https://x.com/",
+ secFetchSite: String = "same-origin",
+ ): String =
+ textRaw(
+ method = method,
+ url = url,
+ params = params,
+ body = body,
+ headers = headers,
+ referrer = referrer,
+ secFetchSite = secFetchSite,
+ ).text
+
+ suspend fun v1Json(
+ queryName: String,
+ params: Map = emptyMap(),
+ body: Any? = null,
+ headers: Map = emptyMap(),
+ ): JsonObject {
+ val operation =
+ EmusksV11Operations[queryName]
+ ?: throw EmusksApiException("v1.1 endpoint $queryName not found")
+ return restJson(operation, params, body, headers)
+ }
+
+ suspend fun v1Response(
+ serializer: KSerializer,
+ queryName: String,
+ params: Map = emptyMap(),
+ body: Any? = null,
+ headers: Map = emptyMap(),
+ ): Response {
+ val operation =
+ EmusksV11Operations[queryName]
+ ?: throw EmusksApiException("v1.1 endpoint $queryName not found")
+ return restRaw(operation, params, body, headers).toResponse(serializer)
+ }
+
+ suspend fun v2Json(
+ queryName: String,
+ params: Map = emptyMap(),
+ body: Any? = null,
+ headers: Map = emptyMap(),
+ ): JsonObject {
+ val operation =
+ EmusksV2Operations[queryName]
+ ?: throw EmusksApiException("v2 endpoint $queryName not found")
+ return restJson(operation, params, body, headers)
+ }
+
+ suspend fun v2Response(
+ serializer: KSerializer,
+ queryName: String,
+ params: Map = emptyMap(),
+ body: Any? = null,
+ headers: Map = emptyMap(),
+ ): Response {
+ val operation =
+ EmusksV2Operations[queryName]
+ ?: throw EmusksApiException("v2 endpoint $queryName not found")
+ return restRaw(operation, params, body, headers).toResponse(serializer)
+ }
+
+ private suspend fun restJson(
+ operation: EmusksRestOperation,
+ params: Map,
+ body: Any?,
+ headers: Map,
+ ): JsonObject = restRaw(operation, params, body, headers).json
+
+ private suspend fun restRaw(
+ operation: EmusksRestOperation,
+ params: Map,
+ body: Any?,
+ headers: Map,
+ ): EmusksJsonResponse {
+ val urlBuilder = URLBuilder(operation.url)
+ params.forEach { (key, value) -> urlBuilder.parameters.append(key, value) }
+ return requestJsonResponse(
+ method = operation.method,
+ url = urlBuilder.buildString(),
+ referrer = "https://x.com/",
+ secFetchSite = "same-origin",
+ body = body,
+ extraHeaders = headers,
+ )
+ }
+
+ private suspend fun requestJson(
+ method: EmusksHttpMethod,
+ url: String,
+ referrer: String,
+ secFetchSite: String,
+ body: Any?,
+ extraHeaders: Map,
+ ): JsonObject = requestJsonResponse(method, url, referrer, secFetchSite, body, extraHeaders).json
+
+ private suspend fun jsonRaw(
+ method: EmusksHttpMethod,
+ url: String,
+ params: Map,
+ body: Any?,
+ headers: Map,
+ referrer: String,
+ secFetchSite: String,
+ ): EmusksJsonResponse {
+ val urlBuilder = URLBuilder(url)
+ params.forEach { (key, value) ->
+ if (value != null) {
+ urlBuilder.parameters.append(key, value)
+ }
+ }
+ return requestJsonResponse(
+ method = method,
+ url = urlBuilder.buildString(),
+ referrer = referrer,
+ secFetchSite = secFetchSite,
+ body = body,
+ extraHeaders = headers,
+ )
+ }
+
+ private suspend fun requestJsonResponse(
+ method: EmusksHttpMethod,
+ url: String,
+ referrer: String,
+ secFetchSite: String,
+ body: Any?,
+ extraHeaders: Map,
+ ): EmusksJsonResponse {
+ val textResponse = requestTextResponse(method, url, referrer, secFetchSite, body, extraHeaders)
+ val responseText = textResponse.text
+ if (textResponse.response.status == HttpStatusCode.Forbidden) {
+ loginExpiredException?.let { throw it() }
+ }
+ if (responseText.isBlank()) {
+ throw EmusksApiException("empty response")
+ }
+ val json = JSON_WITH_ENCODE_DEFAULT.decodeFromString(JsonElement.serializer(), responseText)
+ val jsonObject = json.jsonObject
+ val errors = jsonObject["errors"]?.jsonArray.orEmpty()
+ val errorCode =
+ errors
+ .firstOrNull()
+ ?.jsonObject
+ ?.get("code")
+ ?.jsonPrimitive
+ ?.longOrNull
+ if (errorCode == 215L) {
+ loginExpiredException?.let { throw it() }
+ }
+ val hasData = (jsonObject["data"] as? JsonObject)?.isNotEmpty() == true
+ if (errors.isNotEmpty() && !hasData) {
+ val message =
+ errors.joinToString(", ") { error ->
+ runCatching {
+ error.jsonObject["message"]?.jsonPrimitive?.content
+ }.getOrNull() ?: error.toString()
+ }
+ throw EmusksApiException(message)
+ }
+ return EmusksJsonResponse(textResponse.response, responseText, jsonObject)
+ }
+
+ private suspend fun textRaw(
+ method: EmusksHttpMethod,
+ url: String,
+ params: Map,
+ body: Any?,
+ headers: Map,
+ referrer: String,
+ secFetchSite: String,
+ ): EmusksTextResponse {
+ val urlBuilder = URLBuilder(url)
+ params.forEach { (key, value) ->
+ if (value != null) {
+ urlBuilder.parameters.append(key, value)
+ }
+ }
+ return requestTextResponse(
+ method = method,
+ url = urlBuilder.buildString(),
+ referrer = referrer,
+ secFetchSite = secFetchSite,
+ body = body,
+ extraHeaders = headers,
+ )
+ }
+
+ private suspend fun requestTextResponse(
+ method: EmusksHttpMethod,
+ url: String,
+ referrer: String,
+ secFetchSite: String,
+ body: Any?,
+ extraHeaders: Map,
+ ): EmusksTextResponse {
+ val response =
+ client.request(url) {
+ this.method = method.toKtor()
+ val cookie = currentCookie()
+ val path = Url(url).encodedPath
+ val transactionId = transactionIdProvider?.generate(method.name, path)
+ headers {
+ append(HttpHeaders.Accept, "*/*")
+ append(HttpHeaders.Authorization, "Bearer ${clientProfile.bearer}")
+ append(HttpHeaders.Cookie, cookie.toHeader())
+ append(HttpHeaders.Referrer, referrer)
+ append(HttpHeaders.UserAgent, clientProfile.fingerprint.userAgent)
+ append("x-twitter-client-language", "en")
+ append("x-twitter-active-user", "yes")
+ append("x-twitter-auth-type", "OAuth2Session")
+ cookie.csrfToken?.let { append("x-csrf-token", it) }
+ cookie.guestToken?.let { append("x-guest-token", it) }
+ actingAsUserId?.let { append("x-act-as-user-id", it) }
+ emusksBrowserHeaders(secFetchSite = secFetchSite)
+ transactionId?.takeIf { it.isNotBlank() }?.let { append("x-client-transaction-id", it) }
+ extraHeaders.forEach { (key, value) -> append(key, value) }
+ }
+ if (body != null) {
+ when (body) {
+ is JsonObject -> {
+ contentType(ContentType.Application.Json)
+ setBody(JSON_WITH_ENCODE_DEFAULT.encodeToString(JsonObject.serializer(), body))
+ }
+ is FormDataContent -> setBody(body)
+ else -> setBody(body)
+ }
+ }
+ }
+ val responseText = response.bodyAsText()
+ if (response.status == HttpStatusCode.Forbidden) {
+ loginExpiredException?.let { throw it() }
+ }
+ return EmusksTextResponse(response, responseText)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun EmusksJsonResponse.toResponse(serializer: KSerializer): Response =
+ if (response.status.isSuccess()) {
+ Response.success(
+ JSON_WITH_ENCODE_DEFAULT.decodeFromString(serializer, text),
+ response,
+ ) as Response
+ } else {
+ Response.error(json, response) as Response
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun EmusksTextResponse.toUnitResponse(): Response =
+ if (response.status.isSuccess()) {
+ Response.success(Unit, response) as Response
+ } else {
+ Response.error(text, response) as Response
+ }
+
+ private suspend fun currentCookie(): EmusksCookie {
+ val cookieHeader =
+ cookieProvider()
+ ?: throw EmusksApiException("missing cookie")
+ val cookie = EmusksCookie.parse(cookieHeader)
+ if (cookie.authToken == null) {
+ throw EmusksApiException("missing auth_token")
+ }
+ return cookie
+ }
+
+ private fun mergeVariables(
+ variables: JsonObject?,
+ bodyVariables: JsonObject?,
+ ): JsonObject =
+ buildJsonObject {
+ variables?.forEach { (key, value) -> put(key, value) }
+ bodyVariables?.forEach { (key, value) -> put(key, value) }
+ }
+
+ private fun EmusksHttpMethod.toKtor(): HttpMethod =
+ when (this) {
+ EmusksHttpMethod.GET -> HttpMethod.Get
+ EmusksHttpMethod.POST -> HttpMethod.Post
+ EmusksHttpMethod.PUT -> HttpMethod.Put
+ EmusksHttpMethod.DELETE -> HttpMethod.Delete
+ }
+
+ private fun io.ktor.http.HeadersBuilder.emusksBrowserHeaders(secFetchSite: String) {
+ clientProfile.defaultHeaders.forEach { (key, value) ->
+ append(key, if (key == "sec-fetch-site") secFetchSite else value)
+ }
+ }
+
+ companion object {
+ fun formBody(fields: Map): FormDataContent =
+ FormDataContent(
+ Parameters.build {
+ fields.forEach { (key, value) ->
+ if (value != null) {
+ append(key, value)
+ }
+ }
+ },
+ )
+
+ fun extractInitialUserId(html: String): String? {
+ val initialStateMatch =
+ Regex("""window\.__INITIAL_STATE__\s*=\s*(\{[\s\S]*?});""")
+ .find(html)
+ if (initialStateMatch != null) {
+ val initialState =
+ runCatching {
+ JSON_WITH_ENCODE_DEFAULT.decodeFromString(
+ JsonObject.serializer(),
+ initialStateMatch.groupValues[1],
+ )
+ }.getOrNull()
+ val entities =
+ initialState
+ ?.get("entities")
+ ?.jsonObject
+ ?.get("users")
+ ?.jsonObject
+ ?.get("entities")
+ ?.jsonObject
+ val firstUser = entities?.values?.firstOrNull()?.jsonObject
+ val id =
+ firstUser?.get("id_str")?.jsonPrimitive?.content
+ ?: firstUser?.get("id")?.jsonPrimitive?.content
+ if (id != null) {
+ return id
+ }
+ }
+ return Regex("\"user_id\":\"([0-9]+)\"")
+ .find(html)
+ ?.groupValues
+ ?.get(1)
+ }
+ }
+}
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatBase64.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatBase64.kt
new file mode 100644
index 0000000000..b7b6bc0ddd
--- /dev/null
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatBase64.kt
@@ -0,0 +1,19 @@
+package dev.dimension.flare.data.network.xqt.xchat
+
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
+
+@OptIn(ExperimentalEncodingApi::class)
+internal object XChatBase64 {
+ fun encode(bytes: ByteArray): String = Base64.Default.encode(bytes)
+
+ fun decode(value: String): ByteArray = Base64.Default.decode(value)
+
+ fun encodeUrl(bytes: ByteArray): String = Base64.UrlSafe.encode(bytes).trimEnd('=')
+
+ fun decodeUrl(value: String): ByteArray {
+ val trimmed = value.trim()
+ val padding = (4 - trimmed.length % 4) % 4
+ return Base64.UrlSafe.decode(trimmed + "=".repeat(padding))
+ }
+}
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatCrypto.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatCrypto.kt
new file mode 100644
index 0000000000..53c00e9266
--- /dev/null
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatCrypto.kt
@@ -0,0 +1,266 @@
+package dev.dimension.flare.data.network.xqt.xchat
+
+import dev.dimension.flare.data.platform.XChatIdentityCredential
+import dev.whyoleg.cryptography.CryptographyProvider
+import dev.whyoleg.cryptography.DelicateCryptographyApi
+import dev.whyoleg.cryptography.algorithms.AES
+import dev.whyoleg.cryptography.algorithms.EC
+import dev.whyoleg.cryptography.algorithms.ECDH
+import dev.whyoleg.cryptography.algorithms.ECDSA
+import dev.whyoleg.cryptography.algorithms.SHA256
+import dev.whyoleg.cryptography.random.CryptographyRandom
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.add
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.put
+import kotlin.uuid.Uuid
+
+internal data class XChatLoadedIdentity(
+ val userId: String,
+ val version: String,
+ val publicKeySpki: ByteArray,
+ val signingPublicKeyB64: String,
+ val identityPrivateKey: ECDH.PrivateKey,
+ val signingPrivateKey: ECDSA.PrivateKey,
+ val conversationKeys: MutableMap = mutableMapOf(),
+ val conversationTokens: MutableMap = mutableMapOf(),
+)
+
+internal data class XChatConversationKey(
+ val key: ByteArray,
+ val version: String,
+)
+
+internal object XChatCrypto {
+ suspend fun loadIdentity(identity: XChatIdentityCredential): XChatLoadedIdentity {
+ val provider = CryptographyProvider.Default
+ val ecdh = provider.get(ECDH)
+ val ecdsa = provider.get(ECDSA)
+ val identityPrivateJwk = identity.identityPrivateJwk ?: error("identityPrivateJwk is required")
+ val signingPrivateJwk = identity.signingPrivateJwk ?: error("signingPrivateJwk is required")
+ return XChatLoadedIdentity(
+ userId = identity.userId,
+ version = identity.version,
+ publicKeySpki = XChatBase64.decode(identity.publicKeyB64),
+ signingPublicKeyB64 = identity.signingPublicKeyB64,
+ identityPrivateKey =
+ ecdh
+ .privateKeyDecoder(EC.Curve.P256)
+ .decodeFromByteArray(
+ EC.PrivateKey.Format.RAW,
+ identityPrivateJwk.p256PrivateScalar("identityPrivateJwk"),
+ ),
+ signingPrivateKey =
+ ecdsa
+ .privateKeyDecoder(EC.Curve.P256)
+ .decodeFromByteArray(
+ EC.PrivateKey.Format.RAW,
+ signingPrivateJwk.p256PrivateScalar("signingPrivateJwk"),
+ ),
+ )
+ }
+
+ suspend fun eciesWrap(
+ conversationKey: ByteArray,
+ recipientSpki: ByteArray,
+ ): String {
+ val provider = CryptographyProvider.Default
+ val ecdh = provider.get(ECDH)
+ val recipientPublicKey =
+ ecdh
+ .publicKeyDecoder(EC.Curve.P256)
+ .decodeFromByteArray(EC.PublicKey.Format.DER, recipientSpki)
+ val ephemeral = ecdh.keyPairGenerator(EC.Curve.P256).generateKey()
+ val sharedSecret =
+ ephemeral
+ .privateKey
+ .sharedSecretGenerator()
+ .generateSharedSecretToByteArray(recipientPublicKey)
+ val ephemeralRaw = ephemeral.publicKey.encodeToByteArray(EC.PublicKey.Format.RAW)
+ val derived = sha256(sharedSecret + byteArrayOf(0, 0, 0, 1) + ephemeralRaw)
+ val encrypted = aesGcmEncrypt(derived.copyOfRange(0, 16), derived.copyOfRange(16, 32), conversationKey)
+ return XChatBase64.encode(ephemeralRaw + encrypted)
+ }
+
+ suspend fun eciesUnwrap(
+ wrappedB64: String,
+ identityPrivateKey: ECDH.PrivateKey,
+ ): ByteArray {
+ val wrapped = XChatBase64.decode(wrappedB64)
+ val ephemeralRaw = wrapped.copyOfRange(0, 65)
+ val ciphertext = wrapped.copyOfRange(65, wrapped.size)
+ val ephemeralPublicKey =
+ CryptographyProvider
+ .Default
+ .get(ECDH)
+ .publicKeyDecoder(EC.Curve.P256)
+ .decodeFromByteArray(EC.PublicKey.Format.RAW, ephemeralRaw)
+ val sharedSecret =
+ identityPrivateKey
+ .sharedSecretGenerator()
+ .generateSharedSecretToByteArray(ephemeralPublicKey)
+ val derived = sha256(sharedSecret + byteArrayOf(0, 0, 0, 1) + ephemeralRaw)
+ return aesGcmDecrypt(derived.copyOfRange(0, 16), derived.copyOfRange(16, 32), ciphertext)
+ }
+
+ fun encryptBody(
+ plaintext: ByteArray,
+ conversationKey: ByteArray,
+ ): ByteArray =
+ XChatSecretBox.encryptBody(
+ plaintext = plaintext,
+ key = conversationKey,
+ nonce = secureRandomBytes(24),
+ )
+
+ fun decryptBody(
+ frame: ByteArray,
+ conversationKey: ByteArray,
+ ): ByteArray? = XChatSecretBox.decryptBody(frame, conversationKey)
+
+ suspend fun eventSignature(
+ identity: XChatLoadedIdentity,
+ conversationToken: String,
+ conversationId: String,
+ conversationKeyVersion: String,
+ frame: ByteArray,
+ ): String {
+ val payload =
+ listOf(
+ "MessageCreateEvent",
+ conversationToken,
+ identity.userId,
+ conversationId,
+ conversationKeyVersion,
+ XChatBase64.encodeUrl(frame),
+ ).joinToString(",")
+ val signature =
+ identity
+ .signingPrivateKey
+ .signatureGenerator(SHA256, ECDSA.SignatureFormat.RAW)
+ .generateSignature(payload.encodeToByteArray())
+ return XChatBase64.encode(
+ XChatThrift.eventSignature(
+ signatureBase64Url = XChatBase64.encodeUrl(signature),
+ publicKeyVersion = identity.version,
+ signingPublicKeyB64 = identity.signingPublicKeyB64,
+ senderId = identity.userId,
+ ),
+ )
+ }
+
+ suspend fun actionSignature(
+ identity: XChatLoadedIdentity,
+ typeName: String,
+ conversationToken: String,
+ conversationId: String,
+ dataElements: List,
+ eventDetailBytes: ByteArray? = null,
+ ): JsonObject {
+ val payload =
+ (
+ listOf(
+ typeName,
+ conversationToken,
+ identity.userId,
+ conversationId,
+ ) + dataElements
+ ).joinToString(",")
+ val signature =
+ identity
+ .signingPrivateKey
+ .signatureGenerator(SHA256, ECDSA.SignatureFormat.RAW)
+ .generateSignature(payload.encodeToByteArray())
+ return buildJsonObject {
+ put("chat_fanout_behavior_version", "V1")
+ put("message_id", Uuid.random().toString())
+ put(
+ "message_event_signature",
+ buildJsonObject {
+ put("signature", XChatBase64.encodeUrl(signature))
+ put("public_key_version", identity.version)
+ put("signature_version", "7")
+ put("signing_public_key", identity.signingPublicKeyB64)
+ put(
+ "message_signing_key_info_list",
+ buildJsonArray {
+ add(
+ buildJsonObject {
+ put("member_id", identity.userId)
+ put("public_key_version", identity.version)
+ put("signing_public_key", identity.signingPublicKeyB64)
+ },
+ )
+ },
+ )
+ },
+ )
+ if (eventDetailBytes != null) {
+ put("encoded_message_event_detail", XChatBase64.encodeUrl(eventDetailBytes))
+ }
+ put("signature_payload", payload)
+ }
+ }
+
+ fun secureRandomBytes(size: Int): ByteArray = CryptographyRandom.nextBytes(size)
+
+ suspend fun sha256(bytes: ByteArray): ByteArray =
+ CryptographyProvider
+ .Default
+ .get(SHA256)
+ .hasher()
+ .hash(bytes)
+
+ @OptIn(DelicateCryptographyApi::class)
+ private suspend fun aesGcmEncrypt(
+ key: ByteArray,
+ iv: ByteArray,
+ plaintext: ByteArray,
+ ): ByteArray =
+ CryptographyProvider
+ .Default
+ .get(AES.GCM)
+ .keyDecoder()
+ .decodeFromByteArray(AES.Key.Format.RAW, key)
+ .cipher()
+ .encryptWithIv(iv, plaintext)
+
+ @OptIn(DelicateCryptographyApi::class)
+ private suspend fun aesGcmDecrypt(
+ key: ByteArray,
+ iv: ByteArray,
+ ciphertext: ByteArray,
+ ): ByteArray =
+ CryptographyProvider
+ .Default
+ .get(AES.GCM)
+ .keyDecoder()
+ .decodeFromByteArray(AES.Key.Format.RAW, key)
+ .cipher()
+ .decryptWithIv(iv, ciphertext)
+
+ private fun JsonObject.p256PrivateScalar(fieldName: String): ByteArray {
+ val encoded =
+ this["d"]
+ ?.jsonPrimitive
+ ?.contentOrNull
+ ?: error("$fieldName.d is required")
+ return XChatBase64
+ .decodeUrl(encoded)
+ .toP256Scalar(fieldName)
+ }
+
+ private fun ByteArray.toP256Scalar(fieldName: String): ByteArray =
+ when {
+ size == P256_PRIVATE_SCALAR_SIZE -> this
+ size < P256_PRIVATE_SCALAR_SIZE ->
+ ByteArray(P256_PRIVATE_SCALAR_SIZE).also { copyInto(it, P256_PRIVATE_SCALAR_SIZE - size) }
+ size == P256_PRIVATE_SCALAR_SIZE + 1 && first() == 0.toByte() -> copyOfRange(1, size)
+ else -> error("$fieldName.d must decode to a P-256 private scalar")
+ }
+
+ private const val P256_PRIVATE_SCALAR_SIZE = 32
+}
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatJuicebox.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatJuicebox.kt
new file mode 100644
index 0000000000..0a169fda46
--- /dev/null
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatJuicebox.kt
@@ -0,0 +1,299 @@
+package dev.dimension.flare.data.network.xqt.xchat
+
+import dev.dimension.flare.common.JSON
+import dev.dimension.flare.data.platform.XChatIdentityCredential
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.add
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.put
+import xyz.juicebox.sdk.kmp.AuthToken
+import xyz.juicebox.sdk.kmp.Client
+import xyz.juicebox.sdk.kmp.Configuration
+import xyz.juicebox.sdk.kmp.RealmId
+
+internal suspend fun XChatService.recoverIdentityWithPin(
+ identity: XChatIdentityCredential,
+ pinCode: String,
+): XChatIdentityCredential {
+ val publicKey = recoverablePublicKey(identity.userId, identity.version)
+ return recoverIdentityWithPin(
+ base = identity,
+ publicKey = publicKey,
+ pinCode = pinCode,
+ )
+}
+
+internal suspend fun XChatService.recoverIdentityWithPin(
+ userId: String,
+ pinCode: String,
+): XChatIdentityCredential {
+ val publicKey = recoverablePublicKey(userId, version = null)
+ return recoverIdentityWithPin(
+ base = publicKey.toIdentityCredential(userId),
+ publicKey = publicKey,
+ pinCode = pinCode,
+ )
+}
+
+private suspend fun XChatService.recoverablePublicKey(
+ userId: String,
+ version: String?,
+): XChatPublicKey =
+ publicKeys(
+ userIds = listOf(userId),
+ includeJuiceboxTokens = true,
+ ).firstOrNull()
+ ?.keys
+ .orEmpty()
+ .firstOrNull { publicKey ->
+ (version == null || publicKey.version == version) &&
+ publicKey.hasJuiceboxTokens
+ }
+ ?: error("XChat public key metadata was not found")
+
+private suspend fun XChatService.recoverIdentityWithPin(
+ base: XChatIdentityCredential,
+ publicKey: XChatPublicKey,
+ pinCode: String,
+): XChatIdentityCredential {
+ val tokenMap =
+ listOfNotNull(
+ publicKey.tokenMap,
+ publicKey.targetTokenMap,
+ ).firstOrNull { it.entries.isNotEmpty() }
+ ?: error("XChat Juicebox token map was not found")
+ val client =
+ Client(
+ configuration = tokenMap.toJuiceboxConfiguration(),
+ authTokens = tokenMap.toJuiceboxAuthTokens(),
+ )
+ val secret =
+ client.recover(
+ pin = pinCode.encodeToByteArray(),
+ )
+ return secret.toRecoveredIdentityCredential(
+ base =
+ base.copy(
+ publicKeyB64 = publicKey.publicKey ?: base.publicKeyB64,
+ signingPublicKeyB64 = publicKey.signingPublicKey ?: base.signingPublicKeyB64,
+ registrationMethod = publicKey.registrationMethod ?: base.registrationMethod,
+ pinBacked = true,
+ ),
+ )
+}
+
+private val XChatPublicKey.hasJuiceboxTokens: Boolean
+ get() =
+ tokenMap?.entries?.isNotEmpty() == true ||
+ targetTokenMap?.entries?.isNotEmpty() == true
+
+private fun XChatPublicKey.toIdentityCredential(userId: String): XChatIdentityCredential =
+ XChatIdentityCredential(
+ userId = userId,
+ version = version ?: error("XChat public key metadata is missing version"),
+ publicKeyB64 = publicKey ?: error("XChat public key metadata is missing public key"),
+ signingPublicKeyB64 = signingPublicKey ?: error("XChat public key metadata is missing signing public key"),
+ registrationMethod = registrationMethod,
+ pinBacked = true,
+ )
+
+private fun XChatJuiceboxTokenMap.toJuiceboxConfiguration(): Configuration {
+ val configurationJson =
+ if (entries.isNotEmpty()) {
+ buildJsonObject {
+ put(
+ "realms",
+ buildJsonArray {
+ entries.forEach { entry ->
+ add(
+ buildJsonObject {
+ put("id", entry.realmId)
+ put("address", entry.address)
+ entry.publicKey?.takeIf { it.isNotBlank() }?.let { put("public_key", it) }
+ },
+ )
+ }
+ },
+ )
+ put("register_threshold", registerThreshold ?: entries.size)
+ put("recover_threshold", recoverThreshold ?: registerThreshold ?: entries.size)
+ put("pin_hashing_mode", "Standard2019")
+ }.toString()
+ } else {
+ listOfNotNull(
+ keyStoreTokenMapJson,
+ realmStateString,
+ ).firstOrNull { it.trim().startsWith("{") }
+ ?: error("XChat Juicebox token map did not contain realm configuration")
+ }
+ return Configuration(configurationJson)
+}
+
+private fun XChatJuiceboxTokenMap.toJuiceboxAuthTokens(): Map {
+ val tokens =
+ entries
+ .mapNotNull { entry ->
+ runCatching {
+ RealmId(entry.realmId) to AuthToken(entry.token)
+ }.getOrNull()
+ }.toMap()
+ require(tokens.isNotEmpty()) { "XChat Juicebox token map did not contain usable auth tokens" }
+ return tokens
+}
+
+internal fun ByteArray.toRecoveredIdentityCredential(base: XChatIdentityCredential): XChatIdentityCredential {
+ val root =
+ runCatching {
+ JSON.parseToJsonElement(decodeToString())
+ }.getOrNull()
+ if (root != null) {
+ return root.toRecoveredIdentityCredential(base)
+ }
+ rawP256ScalarsOrNull()?.let { scalars ->
+ return scalars.toRecoveredIdentityCredentialFromScalars(base)
+ }
+ error("Recovered XChat identity secret is not JSON or raw P-256 scalar data")
+}
+
+internal fun ByteArray.rawP256ScalarsOrNull(): ByteArray? {
+ if (size == XCHAT_JUICEBOX_SECRET_SIZE) {
+ return this
+ }
+ val text = runCatching { decodeToString().trim() }.getOrNull() ?: return null
+ return runCatching { XChatBase64.decode(text) }
+ .getOrNull()
+ ?.takeIf { it.size == XCHAT_JUICEBOX_SECRET_SIZE }
+}
+
+private fun ByteArray.toRecoveredIdentityCredentialFromScalars(base: XChatIdentityCredential): XChatIdentityCredential {
+ val identityScalar = copyOfRange(0, P256_SCALAR_SIZE)
+ val signingScalar = copyOfRange(P256_SCALAR_SIZE, XCHAT_JUICEBOX_SECRET_SIZE)
+ return base.copy(
+ identityPrivateJwk =
+ p256PrivateJwk(
+ privateScalar = identityScalar,
+ publicKeyB64 = base.publicKeyB64,
+ ),
+ signingPrivateJwk =
+ p256PrivateJwk(
+ privateScalar = signingScalar,
+ publicKeyB64 = base.signingPublicKeyB64,
+ ),
+ pinBacked = true,
+ )
+}
+
+private fun p256PrivateJwk(
+ privateScalar: ByteArray,
+ publicKeyB64: String,
+): JsonObject {
+ val publicKey = XChatBase64.decode(publicKeyB64).p256UncompressedPublicKey()
+ val x = publicKey.copyOfRange(1, 1 + P256_SCALAR_SIZE)
+ val y = publicKey.copyOfRange(1 + P256_SCALAR_SIZE, P256_UNCOMPRESSED_PUBLIC_KEY_SIZE)
+ return buildJsonObject {
+ put("kty", "EC")
+ put("crv", "P-256")
+ put("d", XChatBase64.encodeUrl(privateScalar))
+ put("x", XChatBase64.encodeUrl(x))
+ put("y", XChatBase64.encodeUrl(y))
+ }
+}
+
+private fun ByteArray.p256UncompressedPublicKey(): ByteArray =
+ when {
+ size == P256_UNCOMPRESSED_PUBLIC_KEY_SIZE && first() == P256_UNCOMPRESSED_PUBLIC_KEY_PREFIX -> this
+ size > P256_UNCOMPRESSED_PUBLIC_KEY_SIZE &&
+ this[size - P256_UNCOMPRESSED_PUBLIC_KEY_SIZE] == P256_UNCOMPRESSED_PUBLIC_KEY_PREFIX ->
+ copyOfRange(size - P256_UNCOMPRESSED_PUBLIC_KEY_SIZE, size)
+ else -> error("XChat public key is not an uncompressed P-256 public key")
+ }
+
+private const val P256_SCALAR_SIZE = 32
+private const val P256_UNCOMPRESSED_PUBLIC_KEY_SIZE = 65
+private const val P256_UNCOMPRESSED_PUBLIC_KEY_PREFIX: Byte = 0x04
+private const val XCHAT_JUICEBOX_SECRET_SIZE = P256_SCALAR_SIZE * 2
+
+private fun JsonElement.toRecoveredIdentityCredential(base: XChatIdentityCredential): XChatIdentityCredential {
+ val identityElement =
+ when (this) {
+ is JsonObject ->
+ this["xchatIdentity"]
+ ?: this["identity"]
+ ?: this["keyStore"]
+ ?: this
+ else -> this
+ }
+ if (identityElement is JsonPrimitive) {
+ val nestedJson = identityElement.contentOrNull?.takeIf { it.trim().startsWith("{") }
+ if (nestedJson != null) {
+ return JSON.parseToJsonElement(nestedJson).toRecoveredIdentityCredential(base)
+ }
+ }
+ val decoded =
+ runCatching {
+ JSON.decodeFromJsonElement(
+ XChatIdentityCredential.serializer(),
+ identityElement,
+ )
+ }.getOrNull()
+ if (decoded?.identityPrivateJwk != null && decoded.signingPrivateJwk != null) {
+ return base.copy(
+ identityPrivateJwk = decoded.identityPrivateJwk,
+ signingPrivateJwk = decoded.signingPrivateJwk,
+ registrationMethod = decoded.registrationMethod ?: base.registrationMethod,
+ pinBacked = true,
+ )
+ }
+ val identityObject = identityElement as? JsonObject ?: error("Recovered XChat identity secret is not an object")
+ val privateKeys = identityObject.obj("privateKeys") ?: identityObject.obj("private_keys")
+ val identityPrivateJwk =
+ identityObject.firstObject(
+ "identityPrivateJwk",
+ "identity_private_jwk",
+ "identityPrivateKeyJwk",
+ "identity_private_key_jwk",
+ ) ?: privateKeys?.firstObject(
+ "identity",
+ "identityPrivateJwk",
+ "identity_private_jwk",
+ "identityPrivateKeyJwk",
+ "identity_private_key_jwk",
+ ) ?: error("Recovered XChat identity secret is missing identity private JWK")
+ val signingPrivateJwk =
+ identityObject.firstObject(
+ "signingPrivateJwk",
+ "signing_private_jwk",
+ "signingPrivateKeyJwk",
+ "signing_private_key_jwk",
+ ) ?: privateKeys?.firstObject(
+ "signing",
+ "signingPrivateJwk",
+ "signing_private_jwk",
+ "signingPrivateKeyJwk",
+ "signing_private_key_jwk",
+ ) ?: error("Recovered XChat identity secret is missing signing private JWK")
+ return base.copy(
+ identityPrivateJwk = identityPrivateJwk,
+ signingPrivateJwk = signingPrivateJwk,
+ pinBacked = true,
+ )
+}
+
+private fun JsonObject.obj(name: String): JsonObject? =
+ when (val value = this[name]) {
+ JsonNull,
+ null,
+ -> null
+ is JsonObject -> value
+ else -> null
+ }
+
+private fun JsonObject.firstObject(vararg names: String): JsonObject? =
+ names.firstNotNullOfOrNull { obj(it) }
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatOperations.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatOperations.kt
new file mode 100644
index 0000000000..0a0e5f9bc3
--- /dev/null
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatOperations.kt
@@ -0,0 +1,326 @@
+package dev.dimension.flare.data.network.xqt.xchat
+
+internal data class XChatOperation(
+ val id: String,
+ val document: String,
+)
+
+internal object XChatOperations {
+ private const val TOKEN_MAP_FRAGMENT =
+ "fragment TokenMapFragment on KeyStoreTokenMap { __typename realm_state realm_state_string max_guess_count recover_threshold register_threshold token_map { __typename key value { __typename token address public_key } } key_store_token_map_json }"
+
+ private val operations =
+ mapOf(
+ "GetPublicKeys" to
+ XChatOperation(
+ id = "GJQbOZALDO5D3Zp2IZhH6w",
+ document =
+ """
+ query GetPublicKeys(
+ ${'$'}ids: [NumericString!]!,
+ ${'$'}include_juicebox_tokens: Boolean = false
+ ) {
+ user_results_by_rest_ids(rest_ids: ${'$'}ids, safety_level: XChat) {
+ __typename
+ ... on UserResults {
+ rest_id
+ id
+ result {
+ __typename
+ ... on User {
+ chat_permissions {
+ __typename
+ can_dm
+ can_dm_on_xchat
+ dm_blocking
+ passes_premium_check
+ }
+ ...UserPublicKeysFragment
+ }
+ }
+ }
+ }
+ }
+ $TOKEN_MAP_FRAGMENT
+ fragment UserPublicKeysFragment on User {
+ __typename
+ get_public_keys {
+ __typename
+ ... on GetPublicKeysResult {
+ public_keys_with_token_map {
+ __typename
+ public_key_with_metadata {
+ __typename
+ public_key {
+ __typename
+ public_key
+ signing_public_key
+ identity_public_key_signature
+ registration_method
+ }
+ version
+ }
+ token_map @include(if: ${'$'}include_juicebox_tokens) {
+ __typename
+ ...TokenMapFragment
+ }
+ target_token_map @include(if: ${'$'}include_juicebox_tokens) {
+ __typename
+ ...TokenMapFragment
+ }
+ }
+ is_managed_pin_user
+ registration_method
+ }
+ ... on GetPublicKeysError {
+ error
+ }
+ }
+ }
+ """.trimIndent(),
+ ),
+ "AddEncryptedConversationKeysMutation" to
+ XChatOperation(
+ id = "4V1KC8ue2tHHvRuIzeczdg",
+ document =
+ """
+ mutation AddEncryptedConversationKeysMutation(
+ ${'$'}conversation_id: String!,
+ ${'$'}conversation_key_version: NumericString!,
+ ${'$'}conversation_participant_keys: [ApiConversationParticipantKeyInput!]!,
+ ${'$'}base64_encoded_key_rotation: String,
+ ${'$'}action_signatures: [ActionSignatureInput!],
+ ${'$'}ttl_msec: NumericString
+ ) {
+ xchat_add_encrypted_conversation_key(
+ conversation_id: ${'$'}conversation_id,
+ conversation_key_version: ${'$'}conversation_key_version,
+ conversation_participant_keys: ${'$'}conversation_participant_keys,
+ base64_encoded_key_rotation: ${'$'}base64_encoded_key_rotation,
+ safety_level: XChat,
+ action_signatures: ${'$'}action_signatures,
+ ttl_msec: ${'$'}ttl_msec
+ ) {
+ __typename
+ error_code
+ conversation_key_change_sequence_id
+ }
+ }
+ """.trimIndent(),
+ ),
+ "GenerateXChatTokenMutation" to
+ XChatOperation(
+ id = "Qh3fZRjPPtPoHYR_2sCZsA",
+ document =
+ """
+ mutation GenerateXChatTokenMutation {
+ user_get_x_chat_auth_token(safety_level: XChat) {
+ __typename
+ error_code
+ token
+ }
+ }
+ """.trimIndent(),
+ ),
+ "DmAvPermissionsQuery" to
+ XChatOperation(
+ id = "kfX5AHDKZrivyHwCaz68mQ",
+ document =
+ """
+ query DmAvPermissionsQuery(${'$'}recipient_ids: [NumericString!]!) {
+ get_av_permissions(
+ recipient_ids: ${'$'}recipient_ids,
+ safety_level: DirectMessagesConversationTimeline
+ ) {
+ __typename
+ result {
+ __typename
+ can_dm
+ error_code
+ }
+ }
+ }
+ """.trimIndent(),
+ ),
+ "SendMessageCreateMutation" to
+ XChatOperation(
+ id = "TWRPP7gnKwV_R8-tE-Dd3Q",
+ document =
+ """
+ mutation SendMessageCreateMutation(
+ ${'$'}conversation_id: String!,
+ ${'$'}message_id: String!,
+ ${'$'}conversation_token: String,
+ ${'$'}encoded_message_create_event: String,
+ ${'$'}encoded_message_event_signature: String
+ ) {
+ xchat_send_create_message_event(
+ conversation_id: ${'$'}conversation_id,
+ conversation_token: ${'$'}conversation_token,
+ encoded_message_create_event: ${'$'}encoded_message_create_event,
+ encoded_message_event_signature: ${'$'}encoded_message_event_signature,
+ message_id: ${'$'}message_id,
+ safety_level: XChat
+ ) {
+ __typename
+ encoded_message_event
+ }
+ }
+ """.trimIndent(),
+ ),
+ "DeleteMessageMutation" to
+ XChatOperation(
+ id = "4gsDQKEmYkOtvsSIpHXdQA",
+ document =
+ """
+ mutation DeleteMessageMutation(
+ ${'$'}sequence_ids: [String!],
+ ${'$'}conversation_id: String,
+ ${'$'}delete_message_action: DeleteMessageActionInput,
+ ${'$'}action_signatures: [ActionSignatureInput!]
+ ) {
+ xchat_delete_messages(
+ safety_level: XChat,
+ sequence_ids: ${'$'}sequence_ids,
+ conversation_id: ${'$'}conversation_id,
+ delete_message_action: ${'$'}delete_message_action,
+ action_signatures: ${'$'}action_signatures
+ ) {
+ __typename
+ error_code
+ }
+ }
+ """.trimIndent(),
+ ),
+ "GetInitialXChatPageQuery" to
+ XChatOperation(
+ id = "XbDtnn4iqMRO13uK4cgqCA",
+ document =
+ """
+ query GetInitialXChatPageQuery(
+ ${'$'}max_local_sequence_id: NumericString,
+ ${'$'}query_settings: XChatPageQuerySettingsInput,
+ ${'$'}message_pull_version: Int,
+ ${'$'}include_participants_results_for_inbox_preview: Boolean! = true
+ ) {
+ get_initial_chat_page(
+ max_local_sequence_id: ${'$'}max_local_sequence_id,
+ query_settings: ${'$'}query_settings,
+ message_pull_version: ${'$'}message_pull_version,
+ safety_level: XChat
+ ) {
+ __typename
+ inboxCursor: cursor {
+ __typename
+ ... on XChatGetInboxPageContinueCursor {
+ cursor_id
+ graph_snapshot_id
+ graph_snapshot_restarted
+ }
+ ... on XChatGetInboxPageEndCursor {
+ inbox_exhausted
+ graph_snapshot_id
+ }
+ }
+ items {
+ __typename
+ latest_message_events
+ latest_notifiable_message_create_event
+ latest_non_notifiable_message_create_event
+ latest_conversation_key_change_events
+ latest_message_sequence_id
+ latest_read_events_per_participant {
+ __typename
+ participant_id_results {
+ __typename
+ rest_id
+ }
+ latest_mark_conversation_read_event
+ }
+ conversation_detail {
+ __typename
+ ... on XChatDirectConversationDetail {
+ conversation_id
+ is_muted
+ participants_results @include(if: ${'$'}include_participants_results_for_inbox_preview) {
+ __typename
+ ...XChatUserResultFragment
+ }
+ }
+ ... on XChatGroupConversationDetail {
+ conversation_id
+ is_muted
+ group_metadata {
+ __typename
+ group_name
+ group_avatar_url
+ }
+ group_members_results {
+ __typename
+ rest_id
+ }
+ participants_results @include(if: ${'$'}include_participants_results_for_inbox_preview) {
+ __typename
+ ...XChatUserResultFragment
+ }
+ }
+ }
+ }
+ encoded_message_events
+ message_requests_count
+ message_pull_version
+ max_user_sequence_id
+ }
+ }
+ fragment XChatUserResultFragment on UserResults {
+ __typename
+ rest_id
+ result {
+ __typename
+ ... on User {
+ rest_id
+ avatar {
+ __typename
+ image_url
+ }
+ core {
+ __typename
+ name
+ screen_name
+ created_at_ms
+ }
+ }
+ }
+ }
+ """.trimIndent(),
+ ),
+ "GetConversationPageQuery" to
+ XChatOperation(
+ id = "IVlXls9JTnbgQ1gxsGAfJA",
+ document =
+ """
+ query GetConversationPageQuery(
+ ${'$'}conversation_id: String,
+ ${'$'}min_local_sequence_id: NumericString!,
+ ${'$'}min_conversation_key_version: NumericString!,
+ ${'$'}query_settings: XChatPageQuerySettingsInput
+ ) {
+ get_conversation_page(
+ conversation_id: ${'$'}conversation_id,
+ min_local_sequence_id: ${'$'}min_local_sequence_id,
+ min_conversation_key_version: ${'$'}min_conversation_key_version,
+ query_settings: ${'$'}query_settings,
+ safety_level: XChat
+ ) {
+ __typename
+ encoded_message_events
+ missing_conversation_key_change_events
+ has_more
+ }
+ }
+ """.trimIndent(),
+ ),
+ )
+
+ operator fun get(name: String): XChatOperation? = operations[name]
+}
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatSecretBox.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatSecretBox.kt
new file mode 100644
index 0000000000..bd99349fe4
--- /dev/null
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatSecretBox.kt
@@ -0,0 +1,374 @@
+package dev.dimension.flare.data.network.xqt.xchat
+
+import kotlin.experimental.xor
+
+internal object XChatSecretBox {
+ fun encryptBody(
+ plaintext: ByteArray,
+ key: ByteArray,
+ nonce: ByteArray,
+ ): ByteArray {
+ require(key.size == KEY_SIZE) { "secretbox key must be $KEY_SIZE bytes" }
+ require(nonce.size == NONCE_SIZE) { "secretbox nonce must be $NONCE_SIZE bytes" }
+ val box = secretBox(plaintext, nonce, key)
+ return nonce + box
+ }
+
+ fun decryptBody(
+ frame: ByteArray,
+ key: ByteArray,
+ ): ByteArray? {
+ require(key.size == KEY_SIZE) { "secretbox key must be $KEY_SIZE bytes" }
+ if (frame.size < NONCE_SIZE + TAG_SIZE) return null
+ val nonce = frame.copyOfRange(0, NONCE_SIZE)
+ val box = frame.copyOfRange(NONCE_SIZE, frame.size)
+ return secretBoxOpen(box, nonce, key)
+ }
+
+ private fun secretBox(
+ message: ByteArray,
+ nonce: ByteArray,
+ key: ByteArray,
+ ): ByteArray {
+ val stream = xsalsa20Stream(message.size + ZERO_PREFIX_SIZE, nonce, key)
+ val cipher = ByteArray(message.size)
+ for (index in message.indices) {
+ cipher[index] = message[index] xor stream[index + ZERO_PREFIX_SIZE]
+ }
+ val tag = poly1305(cipher, stream.copyOfRange(0, AUTH_KEY_SIZE))
+ return tag + cipher
+ }
+
+ private fun secretBoxOpen(
+ box: ByteArray,
+ nonce: ByteArray,
+ key: ByteArray,
+ ): ByteArray? {
+ if (box.size < TAG_SIZE) return null
+ val tag = box.copyOfRange(0, TAG_SIZE)
+ val cipher = box.copyOfRange(TAG_SIZE, box.size)
+ val stream = xsalsa20Stream(cipher.size + ZERO_PREFIX_SIZE, nonce, key)
+ val expected = poly1305(cipher, stream.copyOfRange(0, AUTH_KEY_SIZE))
+ if (!constantTimeEquals(tag, expected)) return null
+ return ByteArray(cipher.size) { index ->
+ cipher[index] xor stream[index + ZERO_PREFIX_SIZE]
+ }
+ }
+
+ private fun xsalsa20Stream(
+ length: Int,
+ nonce: ByteArray,
+ key: ByteArray,
+ ): ByteArray {
+ val subKey = hSalsa20(nonce.copyOfRange(0, 16), key)
+ return salsa20Stream(length, nonce.copyOfRange(16, 24), subKey)
+ }
+
+ private fun salsa20Stream(
+ length: Int,
+ nonce: ByteArray,
+ key: ByteArray,
+ ): ByteArray {
+ val output = ByteArray(length)
+ var counter = 0L
+ var offset = 0
+ while (offset < length) {
+ val block = salsa20Block(nonce, key, counter)
+ val count = minOf(block.size, length - offset)
+ block.copyInto(output, offset, endIndex = count)
+ offset += count
+ counter++
+ }
+ return output
+ }
+
+ private fun salsa20Block(
+ nonce: ByteArray,
+ key: ByteArray,
+ counter: Long,
+ ): ByteArray {
+ val state =
+ intArrayOf(
+ load32(SIGMA, 0),
+ load32(key, 0),
+ load32(key, 4),
+ load32(key, 8),
+ load32(key, 12),
+ load32(SIGMA, 4),
+ load32(nonce, 0),
+ load32(nonce, 4),
+ counter.toInt(),
+ (counter ushr 32).toInt(),
+ load32(SIGMA, 8),
+ load32(key, 16),
+ load32(key, 20),
+ load32(key, 24),
+ load32(key, 28),
+ load32(SIGMA, 12),
+ )
+ val working = salsaRounds(state)
+ val output = ByteArray(64)
+ for (index in 0 until 16) {
+ store32(output, index * 4, working[index] + state[index])
+ }
+ return output
+ }
+
+ private fun hSalsa20(
+ nonce: ByteArray,
+ key: ByteArray,
+ ): ByteArray {
+ val state =
+ intArrayOf(
+ load32(SIGMA, 0),
+ load32(key, 0),
+ load32(key, 4),
+ load32(key, 8),
+ load32(key, 12),
+ load32(SIGMA, 4),
+ load32(nonce, 0),
+ load32(nonce, 4),
+ load32(nonce, 8),
+ load32(nonce, 12),
+ load32(SIGMA, 8),
+ load32(key, 16),
+ load32(key, 20),
+ load32(key, 24),
+ load32(key, 28),
+ load32(SIGMA, 12),
+ )
+ val working = salsaRounds(state)
+ val output = ByteArray(KEY_SIZE)
+ intArrayOf(0, 5, 10, 15, 6, 7, 8, 9).forEachIndexed { index, stateIndex ->
+ store32(output, index * 4, working[stateIndex])
+ }
+ return output
+ }
+
+ private fun salsaRounds(input: IntArray): IntArray {
+ val x = input.copyOf()
+ repeat(10) {
+ x[4] = x[4] xor rotateLeft(x[0] + x[12], 7)
+ x[8] = x[8] xor rotateLeft(x[4] + x[0], 9)
+ x[12] = x[12] xor rotateLeft(x[8] + x[4], 13)
+ x[0] = x[0] xor rotateLeft(x[12] + x[8], 18)
+ x[9] = x[9] xor rotateLeft(x[5] + x[1], 7)
+ x[13] = x[13] xor rotateLeft(x[9] + x[5], 9)
+ x[1] = x[1] xor rotateLeft(x[13] + x[9], 13)
+ x[5] = x[5] xor rotateLeft(x[1] + x[13], 18)
+ x[14] = x[14] xor rotateLeft(x[10] + x[6], 7)
+ x[2] = x[2] xor rotateLeft(x[14] + x[10], 9)
+ x[6] = x[6] xor rotateLeft(x[2] + x[14], 13)
+ x[10] = x[10] xor rotateLeft(x[6] + x[2], 18)
+ x[3] = x[3] xor rotateLeft(x[15] + x[11], 7)
+ x[7] = x[7] xor rotateLeft(x[3] + x[15], 9)
+ x[11] = x[11] xor rotateLeft(x[7] + x[3], 13)
+ x[15] = x[15] xor rotateLeft(x[11] + x[7], 18)
+ x[1] = x[1] xor rotateLeft(x[0] + x[3], 7)
+ x[2] = x[2] xor rotateLeft(x[1] + x[0], 9)
+ x[3] = x[3] xor rotateLeft(x[2] + x[1], 13)
+ x[0] = x[0] xor rotateLeft(x[3] + x[2], 18)
+ x[6] = x[6] xor rotateLeft(x[5] + x[4], 7)
+ x[7] = x[7] xor rotateLeft(x[6] + x[5], 9)
+ x[4] = x[4] xor rotateLeft(x[7] + x[6], 13)
+ x[5] = x[5] xor rotateLeft(x[4] + x[7], 18)
+ x[11] = x[11] xor rotateLeft(x[10] + x[9], 7)
+ x[8] = x[8] xor rotateLeft(x[11] + x[10], 9)
+ x[9] = x[9] xor rotateLeft(x[8] + x[11], 13)
+ x[10] = x[10] xor rotateLeft(x[9] + x[8], 18)
+ x[12] = x[12] xor rotateLeft(x[15] + x[14], 7)
+ x[13] = x[13] xor rotateLeft(x[12] + x[15], 9)
+ x[14] = x[14] xor rotateLeft(x[13] + x[12], 13)
+ x[15] = x[15] xor rotateLeft(x[14] + x[13], 18)
+ }
+ return x
+ }
+
+ private fun poly1305(
+ message: ByteArray,
+ key: ByteArray,
+ ): ByteArray {
+ val r0 = load32Long(key, 0) and MASK_26
+ val r1 = (load32Long(key, 3) ushr 2) and 0x3ffff03L
+ val r2 = (load32Long(key, 6) ushr 4) and 0x3ffc0ffL
+ val r3 = (load32Long(key, 9) ushr 6) and 0x3f03fffL
+ val r4 = (load32Long(key, 12) ushr 8) and 0x00fffffL
+ val s1 = r1 * 5
+ val s2 = r2 * 5
+ val s3 = r3 * 5
+ val s4 = r4 * 5
+ var h0 = 0L
+ var h1 = 0L
+ var h2 = 0L
+ var h3 = 0L
+ var h4 = 0L
+ var offset = 0
+ while (offset < message.size) {
+ val block = ByteArray(16)
+ val blockSize = minOf(16, message.size - offset)
+ message.copyInto(block, endIndex = offset + blockSize, startIndex = offset)
+ val hibit =
+ if (blockSize == 16) {
+ 1L shl 24
+ } else {
+ block[blockSize] = 1
+ 0L
+ }
+ val t0 = load32Long(block, 0)
+ val t1 = load32Long(block, 4)
+ val t2 = load32Long(block, 8)
+ val t3 = load32Long(block, 12)
+ h0 += t0 and MASK_26
+ h1 += ((t1 shl 6) or (t0 ushr 26)) and MASK_26
+ h2 += ((t2 shl 12) or (t1 ushr 20)) and MASK_26
+ h3 += ((t3 shl 18) or (t2 ushr 14)) and MASK_26
+ h4 += (t3 ushr 8) or hibit
+
+ val d0 = h0 * r0 + h1 * s4 + h2 * s3 + h3 * s2 + h4 * s1
+ val d1 = h0 * r1 + h1 * r0 + h2 * s4 + h3 * s3 + h4 * s2
+ val d2 = h0 * r2 + h1 * r1 + h2 * r0 + h3 * s4 + h4 * s3
+ val d3 = h0 * r3 + h1 * r2 + h2 * r1 + h3 * r0 + h4 * s4
+ val d4 = h0 * r4 + h1 * r3 + h2 * r2 + h3 * r1 + h4 * r0
+
+ var carry = d0 ushr 26
+ h0 = d0 and MASK_26
+ h1 = d1 + carry
+ carry = h1 ushr 26
+ h1 = h1 and MASK_26
+ h2 = d2 + carry
+ carry = h2 ushr 26
+ h2 = h2 and MASK_26
+ h3 = d3 + carry
+ carry = h3 ushr 26
+ h3 = h3 and MASK_26
+ h4 = d4 + carry
+ carry = h4 ushr 26
+ h4 = h4 and MASK_26
+ h0 += carry * 5
+ carry = h0 ushr 26
+ h0 = h0 and MASK_26
+ h1 += carry
+
+ offset += blockSize
+ }
+
+ var carry = h1 ushr 26
+ h1 = h1 and MASK_26
+ h2 += carry
+ carry = h2 ushr 26
+ h2 = h2 and MASK_26
+ h3 += carry
+ carry = h3 ushr 26
+ h3 = h3 and MASK_26
+ h4 += carry
+ carry = h4 ushr 26
+ h4 = h4 and MASK_26
+ h0 += carry * 5
+ carry = h0 ushr 26
+ h0 = h0 and MASK_26
+ h1 += carry
+
+ var g0 = h0 + 5
+ carry = g0 ushr 26
+ g0 = g0 and MASK_26
+ var g1 = h1 + carry
+ carry = g1 ushr 26
+ g1 = g1 and MASK_26
+ var g2 = h2 + carry
+ carry = g2 ushr 26
+ g2 = g2 and MASK_26
+ var g3 = h3 + carry
+ carry = g3 ushr 26
+ g3 = g3 and MASK_26
+ val g4 = h4 + carry - (1L shl 26)
+ if (g4 >= 0) {
+ h0 = g0
+ h1 = g1
+ h2 = g2
+ h3 = g3
+ h4 = g4
+ }
+
+ val tag = ByteArray(TAG_SIZE)
+ var f = (h0 or (h1 shl 26)) and UINT_MASK
+ f += load32Long(key, 16)
+ carry = f ushr 32
+ store32(tag, 0, f)
+ f = ((h1 ushr 6) or (h2 shl 20)) and UINT_MASK
+ f += load32Long(key, 20) + carry
+ carry = f ushr 32
+ store32(tag, 4, f)
+ f = ((h2 ushr 12) or (h3 shl 14)) and UINT_MASK
+ f += load32Long(key, 24) + carry
+ carry = f ushr 32
+ store32(tag, 8, f)
+ f = ((h3 ushr 18) or (h4 shl 8)) and UINT_MASK
+ f += load32Long(key, 28) + carry
+ store32(tag, 12, f)
+ return tag
+ }
+
+ private fun constantTimeEquals(
+ a: ByteArray,
+ b: ByteArray,
+ ): Boolean {
+ if (a.size != b.size) return false
+ var diff = 0
+ for (index in a.indices) {
+ diff = diff or (a[index].toInt() xor b[index].toInt())
+ }
+ return diff == 0
+ }
+
+ private fun rotateLeft(
+ value: Int,
+ bits: Int,
+ ): Int = (value shl bits) or (value ushr (32 - bits))
+
+ private fun load32(
+ bytes: ByteArray,
+ offset: Int,
+ ): Int =
+ bytes[offset].u() or
+ (bytes[offset + 1].u() shl 8) or
+ (bytes[offset + 2].u() shl 16) or
+ (bytes[offset + 3].u() shl 24)
+
+ private fun load32Long(
+ bytes: ByteArray,
+ offset: Int,
+ ): Long = load32(bytes, offset).toLong() and UINT_MASK
+
+ private fun store32(
+ bytes: ByteArray,
+ offset: Int,
+ value: Int,
+ ) {
+ bytes[offset] = value.toByte()
+ bytes[offset + 1] = (value ushr 8).toByte()
+ bytes[offset + 2] = (value ushr 16).toByte()
+ bytes[offset + 3] = (value ushr 24).toByte()
+ }
+
+ private fun store32(
+ bytes: ByteArray,
+ offset: Int,
+ value: Long,
+ ) {
+ bytes[offset] = value.toByte()
+ bytes[offset + 1] = (value ushr 8).toByte()
+ bytes[offset + 2] = (value ushr 16).toByte()
+ bytes[offset + 3] = (value ushr 24).toByte()
+ }
+
+ private fun Byte.u(): Int = toInt() and 0xff
+
+ private const val KEY_SIZE = 32
+ private const val NONCE_SIZE = 24
+ private const val TAG_SIZE = 16
+ private const val AUTH_KEY_SIZE = 32
+ private const val ZERO_PREFIX_SIZE = 32
+ private const val MASK_26 = 0x3ffffffL
+ private const val UINT_MASK = 0xffffffffL
+ private val SIGMA = "expand 32-byte k".encodeToByteArray()
+}
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatService.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatService.kt
new file mode 100644
index 0000000000..c01199376c
--- /dev/null
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatService.kt
@@ -0,0 +1,1123 @@
+package dev.dimension.flare.data.network.xqt.xchat
+
+import dev.dimension.flare.data.network.xqt.emusks.EmusksApiException
+import dev.dimension.flare.data.network.xqt.emusks.EmusksRawClient
+import dev.dimension.flare.data.platform.XChatIdentityCredential
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.add
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.intOrNull
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
+import kotlinx.serialization.json.put
+import kotlin.time.Clock
+import kotlin.uuid.Uuid
+
+internal class XChatService(
+ private val rawClient: EmusksRawClient,
+) {
+ suspend fun loadIdentity(identity: XChatIdentityCredential): XChatLoadedIdentity = XChatCrypto.loadIdentity(identity)
+
+ suspend fun publicKeys(
+ userIds: List,
+ includeJuiceboxTokens: Boolean = false,
+ ): List {
+ if (userIds.isEmpty()) return emptyList()
+ val response =
+ gql(
+ name = "GetPublicKeys",
+ variables =
+ buildJsonObject {
+ put(
+ "ids",
+ buildJsonArray {
+ userIds.forEach { add(JsonPrimitive(it)) }
+ },
+ )
+ put("include_juicebox_tokens", includeJuiceboxTokens)
+ },
+ )
+ return response
+ .obj("data")
+ ?.array("user_results_by_rest_ids")
+ .orEmpty()
+ .mapNotNull { it.asObjectOrNull()?.toUserPublicKeys() }
+ }
+
+ suspend fun profile(userId: String): XChatUserPublicKeys? = publicKeys(listOf(userId)).firstOrNull()
+
+ suspend fun publicKey(userId: String): XChatPublicKey? = profile(userId)?.keys?.firstOrNull()
+
+ suspend fun permissions(userId: String): XChatPermissions? = profile(userId)?.permissions
+
+ suspend fun isOnXChat(userId: String): Boolean = profile(userId)?.onXChat == true
+
+ suspend fun canMessage(userId: String): Boolean =
+ permissions(userId)?.canDmOnXChat == true
+
+ suspend fun fingerprint(publicKeyB64: String): String =
+ XChatCrypto
+ .sha256(XChatBase64.decode(publicKeyB64))
+ .joinToString("") { byte ->
+ (byte.toInt() and 0xff).toString(radix = 16).padStart(2, '0')
+ }
+ .chunked(4)
+ .joinToString(":")
+
+ suspend fun token(): String? =
+ gql(name = "GenerateXChatTokenMutation")
+ .obj("data")
+ ?.obj("user_get_x_chat_auth_token")
+ ?.string("token")
+
+ suspend fun callPermissions(userIds: List): List {
+ if (userIds.isEmpty()) return emptyList()
+ val response =
+ gql(
+ name = "DmAvPermissionsQuery",
+ variables =
+ buildJsonObject {
+ put(
+ "recipient_ids",
+ buildJsonArray {
+ userIds.forEach { add(JsonPrimitive(it)) }
+ },
+ )
+ },
+ )
+ return response
+ .obj("data")
+ ?.obj("get_av_permissions")
+ ?.array("result")
+ .orEmpty()
+ .mapNotNull { it.asObjectOrNull()?.toCallPermission() }
+ }
+
+ suspend fun initialPage(
+ identity: XChatLoadedIdentity?,
+ maxLocalSequenceId: String? = null,
+ messagePullVersion: Int? = null,
+ viewerId: String? = identity?.userId,
+ ): XChatInboxPage {
+ val response =
+ gql(
+ name = "GetInitialXChatPageQuery",
+ variables =
+ buildJsonObject {
+ put("max_local_sequence_id", maxLocalSequenceId?.let(::JsonPrimitive) ?: JsonNull)
+ put("query_settings", initialPageQuerySettings())
+ put(
+ "message_pull_version",
+ JsonPrimitive(messagePullVersion ?: DEFAULT_INITIAL_MESSAGE_PULL_VERSION),
+ )
+ },
+ )
+ val page = response.obj("data")?.obj("get_initial_chat_page") ?: JsonObject(emptyMap())
+ val conversations =
+ page
+ .array("items")
+ .orEmpty()
+ .mapNotNull { item ->
+ item
+ .asObjectOrNull()
+ ?.toConversation(identity, viewerId)
+ }
+ val cursor = page.obj("cursor") ?: page.obj("inboxCursor")
+ val nextKey =
+ cursor?.string("max_local_sequence_id")
+ ?: cursor?.string("cursor_id")
+ return XChatInboxPage(
+ conversations = conversations,
+ nextKey = nextKey,
+ hasMore = nextKey != null || cursor?.boolean("inbox_exhausted") == false,
+ messageRequestsCount = page.int("message_requests_count"),
+ messagePullVersion = page.int("message_pull_version"),
+ maxUserSequenceId = page.string("max_user_sequence_id"),
+ )
+ }
+
+ suspend fun conversationPage(
+ conversationId: String,
+ identity: XChatLoadedIdentity,
+ before: String? = null,
+ ): XChatConversationPage {
+ val response =
+ gql(
+ name = "GetConversationPageQuery",
+ variables =
+ buildJsonObject {
+ put("conversation_id", conversationId)
+ put("min_local_sequence_id", before ?: MAX_SEQUENCE_ID)
+ put("min_conversation_key_version", MAX_SEQUENCE_ID)
+ put("query_settings", JsonNull)
+ },
+ )
+ val page = response.obj("data")?.obj("get_conversation_page") ?: JsonObject(emptyMap())
+ val cKeyMap =
+ buildConversationKeyMap(
+ keyChangeB64s = page.array("missing_conversation_key_change_events").asStringList(),
+ identity = identity,
+ )
+ identity.rememberConversationKeys(conversationId, cKeyMap)
+ identity.conversationKeys[conversationId]?.let { cached ->
+ if (cKeyMap[cached.version] == null) {
+ cKeyMap[cached.version] = cached.key
+ }
+ }
+ val messages =
+ page
+ .array("encoded_message_events")
+ .asStringList()
+ .mapNotNull { decodeMessageEvent(it, cKeyMap) }
+ return XChatConversationPage(
+ messages = messages,
+ hasMore = page.boolean("has_more") == true,
+ nextKey = messages.minSequenceId(),
+ )
+ }
+
+ suspend fun sendText(
+ conversationId: String,
+ text: String,
+ identity: XChatLoadedIdentity,
+ participantIds: List,
+ ttlMsec: Long? = null,
+ ): XChatSendResult =
+ sendEntry(
+ conversationId = conversationId,
+ holderBytes = XChatThrift.textHolder(text),
+ identity = identity,
+ participantIds = participantIds,
+ ttlMsec = ttlMsec,
+ )
+
+ suspend fun react(
+ conversationId: String,
+ sequenceId: String,
+ emoji: String,
+ identity: XChatLoadedIdentity,
+ participantIds: List,
+ attachmentId: String? = null,
+ ): XChatSendResult =
+ sendEntry(
+ conversationId = conversationId,
+ holderBytes =
+ XChatThrift.reactionAddHolder(
+ sequenceId = sequenceId,
+ emoji = emoji,
+ attachmentId = attachmentId,
+ ),
+ identity = identity,
+ participantIds = participantIds,
+ )
+
+ suspend fun unreact(
+ conversationId: String,
+ sequenceId: String,
+ emoji: String,
+ identity: XChatLoadedIdentity,
+ participantIds: List,
+ attachmentId: String? = null,
+ ): XChatSendResult =
+ sendEntry(
+ conversationId = conversationId,
+ holderBytes =
+ XChatThrift.reactionRemoveHolder(
+ sequenceId = sequenceId,
+ emoji = emoji,
+ attachmentId = attachmentId,
+ ),
+ identity = identity,
+ participantIds = participantIds,
+ )
+
+ suspend fun edit(
+ conversationId: String,
+ sequenceId: String,
+ text: String,
+ identity: XChatLoadedIdentity,
+ participantIds: List,
+ ): XChatSendResult =
+ sendEntry(
+ conversationId = conversationId,
+ holderBytes =
+ XChatThrift.editHolder(
+ sequenceId = sequenceId,
+ updatedText = text,
+ ),
+ identity = identity,
+ participantIds = participantIds,
+ )
+
+ suspend fun markRead(
+ conversationId: String,
+ sequenceId: String,
+ identity: XChatLoadedIdentity,
+ participantIds: List,
+ ): XChatSendResult =
+ sendEntry(
+ conversationId = conversationId,
+ holderBytes =
+ XChatThrift.markReadHolder(
+ sequenceId = sequenceId,
+ seenAtMillis = Clock.System.now().toEpochMilliseconds(),
+ ),
+ identity = identity,
+ participantIds = participantIds,
+ )
+
+ suspend fun markUnread(
+ conversationId: String,
+ sequenceId: String,
+ identity: XChatLoadedIdentity,
+ participantIds: List,
+ ): XChatSendResult =
+ sendEntry(
+ conversationId = conversationId,
+ holderBytes = XChatThrift.markUnreadHolder(sequenceId),
+ identity = identity,
+ participantIds = participantIds,
+ )
+
+ suspend fun pinConversation(
+ conversationId: String,
+ identity: XChatLoadedIdentity,
+ participantIds: List,
+ ): XChatSendResult =
+ sendEntry(
+ conversationId = conversationId,
+ holderBytes = XChatThrift.pinHolder(conversationId),
+ identity = identity,
+ participantIds = participantIds,
+ )
+
+ suspend fun unpinConversation(
+ conversationId: String,
+ identity: XChatLoadedIdentity,
+ participantIds: List,
+ ): XChatSendResult =
+ sendEntry(
+ conversationId = conversationId,
+ holderBytes = XChatThrift.unpinHolder(conversationId),
+ identity = identity,
+ participantIds = participantIds,
+ )
+
+ suspend fun setNickname(
+ conversationId: String,
+ targetUserId: String,
+ nickname: String,
+ identity: XChatLoadedIdentity,
+ participantIds: List,
+ ): XChatSendResult =
+ sendEntry(
+ conversationId = conversationId,
+ holderBytes =
+ XChatThrift.nicknameHolder(
+ userId = targetUserId,
+ nickname = nickname,
+ ),
+ identity = identity,
+ participantIds = participantIds,
+ )
+
+ suspend fun reportScreenCapture(
+ conversationId: String,
+ identity: XChatLoadedIdentity,
+ participantIds: List,
+ recording: Boolean = false,
+ ): XChatSendResult =
+ sendEntry(
+ conversationId = conversationId,
+ holderBytes =
+ XChatThrift.screenCaptureHolder(
+ if (recording) ScreenCaptureType.Recording else ScreenCaptureType.Screenshot,
+ ),
+ identity = identity,
+ participantIds = participantIds,
+ )
+
+ suspend fun deleteMessages(
+ conversationId: String,
+ sequenceIds: List,
+ forEveryone: Boolean = false,
+ identity: XChatLoadedIdentity? = null,
+ ): JsonObject {
+ if (sequenceIds.isEmpty()) return JsonObject(emptyMap())
+ val actionSignatures =
+ if (forEveryone) {
+ val loadedIdentity = identity ?: error("XChat identity is required to delete for everyone")
+ val conversationToken = conversationToken(conversationId, loadedIdentity)
+ val signature =
+ XChatCrypto.actionSignature(
+ identity = loadedIdentity,
+ typeName = "MessageDeleteEvent",
+ conversationToken = conversationToken,
+ conversationId = conversationId,
+ dataElements = listOf("2") + sequenceIds,
+ eventDetailBytes =
+ XChatThrift.deleteEventDetail(
+ sequenceIds = sequenceIds,
+ actionValue = 2,
+ ),
+ )
+ buildJsonArray {
+ add(signature)
+ }
+ } else {
+ JsonNull
+ }
+ val response =
+ gql(
+ name = "DeleteMessageMutation",
+ variables =
+ buildJsonObject {
+ put("conversation_id", conversationId)
+ put(
+ "sequence_ids",
+ buildJsonArray {
+ sequenceIds.forEach { add(JsonPrimitive(it)) }
+ },
+ )
+ put("delete_message_action", if (forEveryone) "DeleteForAll" else "DeleteForSelf")
+ put("action_signatures", actionSignatures)
+ },
+ )
+ return response.obj("data")?.obj("xchat_delete_messages") ?: JsonObject(emptyMap())
+ }
+
+ suspend fun gql(
+ name: String,
+ variables: JsonObject = JsonObject(emptyMap()),
+ ): JsonObject {
+ val operation = XChatOperations[name] ?: throw EmusksApiException("xchat operation $name not found")
+ return rawClient.apolloGraphqlJson(
+ operationId = operation.id,
+ operationName = name,
+ query = operation.document,
+ variables = variables,
+ )
+ }
+
+ private suspend fun sendEntry(
+ conversationId: String,
+ holderBytes: ByteArray,
+ identity: XChatLoadedIdentity,
+ participantIds: List,
+ shouldNotify: Boolean = true,
+ ttlMsec: Long? = null,
+ ): XChatSendResult {
+ val conversationKey = ensureConversationKey(conversationId, identity, participantIds)
+ val conversationToken = conversationToken(conversationId, identity)
+ val frame = XChatCrypto.encryptBody(holderBytes, conversationKey.key)
+ val messageCreateEvent =
+ XChatThrift.messageCreateEvent(
+ frame = frame,
+ conversationKeyVersion = conversationKey.version,
+ shouldNotify = shouldNotify,
+ ttlMsec = ttlMsec,
+ )
+ val signature =
+ XChatCrypto.eventSignature(
+ identity = identity,
+ conversationToken = conversationToken,
+ conversationId = conversationId,
+ conversationKeyVersion = conversationKey.version,
+ frame = frame,
+ )
+ val messageId = Uuid.random().toString()
+ val response =
+ gql(
+ name = "SendMessageCreateMutation",
+ variables =
+ buildJsonObject {
+ put("conversation_id", conversationId)
+ put("message_id", messageId)
+ put("conversation_token", conversationToken.takeIf { it.isNotEmpty() }?.let(::JsonPrimitive) ?: JsonNull)
+ put("encoded_message_create_event", XChatBase64.encode(messageCreateEvent))
+ put("encoded_message_event_signature", signature)
+ },
+ )
+ val encodedEvent =
+ response
+ .obj("data")
+ ?.obj("xchat_send_create_message_event")
+ ?.string("encoded_message_event")
+ val decodedEvent =
+ encodedEvent?.let {
+ decodeMessageEvent(
+ eventB64 = it,
+ cKeyMap = mapOf(conversationKey.version to conversationKey.key),
+ )
+ }
+ return XChatSendResult(
+ conversationId = conversationId,
+ messageId = messageId,
+ sequenceId = XChatThrift.readLeadingSequenceId(encodedEvent),
+ encodedMessageEvent = encodedEvent,
+ decodedEvent = decodedEvent,
+ )
+ }
+
+ private suspend fun ensureConversationKey(
+ conversationId: String,
+ identity: XChatLoadedIdentity,
+ participantIds: List,
+ ): XChatConversationKey {
+ identity.conversationKeys[conversationId]?.let { return it }
+ val distinctParticipantIds = (participantIds + identity.userId).distinct()
+ val recipientKeys =
+ publicKeys(distinctParticipantIds.filterNot { it == identity.userId })
+ .mapNotNull { user ->
+ val key = user.keys.firstOrNull { it.publicKey != null && it.version != null } ?: return@mapNotNull null
+ XChatRecipientKey(
+ userId = user.userId ?: return@mapNotNull null,
+ publicKeySpki = XChatBase64.decode(key.publicKey ?: return@mapNotNull null),
+ version = key.version ?: return@mapNotNull null,
+ )
+ }
+ val missingRecipients = distinctParticipantIds.filterNot { id -> id == identity.userId || recipientKeys.any { it.userId == id } }
+ if (missingRecipients.isNotEmpty()) {
+ throw EmusksApiException("xchat public key not found for ${missingRecipients.joinToString()}")
+ }
+ val conversationKeyBytes = XChatCrypto.secureRandomBytes(32)
+ val version = Clock.System.now().toEpochMilliseconds().toString()
+ val participantKeys =
+ listOf(
+ XChatRecipientKey(
+ userId = identity.userId,
+ publicKeySpki = identity.publicKeySpki,
+ version = identity.version,
+ ),
+ ) + recipientKeys
+ gql(
+ name = "AddEncryptedConversationKeysMutation",
+ variables =
+ buildJsonObject {
+ put("conversation_id", conversationId)
+ put("conversation_key_version", version)
+ put(
+ "conversation_participant_keys",
+ buildJsonArray {
+ participantKeys.forEach { recipient ->
+ add(
+ buildJsonObject {
+ put("user_id", recipient.userId)
+ put(
+ "encrypted_conversation_key",
+ XChatCrypto.eciesWrap(conversationKeyBytes, recipient.publicKeySpki),
+ )
+ put("public_key_version", recipient.version)
+ },
+ )
+ }
+ },
+ )
+ },
+ )
+ return XChatConversationKey(
+ key = conversationKeyBytes,
+ version = version,
+ ).also {
+ identity.conversationKeys[conversationId] = it
+ }
+ }
+
+ private suspend fun conversationToken(
+ conversationId: String,
+ identity: XChatLoadedIdentity,
+ ): String {
+ identity.conversationTokens[conversationId]?.let { return it }
+ val token =
+ runCatching {
+ val page = conversationPage(conversationId = conversationId, identity = identity)
+ page.messages
+ .firstNotNullOfOrNull { it.conversationToken }
+ .orEmpty()
+ }.getOrDefault("")
+ identity.conversationTokens[conversationId] = token
+ return token
+ }
+
+ private suspend fun JsonObject.toConversation(
+ identity: XChatLoadedIdentity?,
+ viewerId: String?,
+ ): XChatConversation? {
+ val detail = obj("conversation_detail") ?: return null
+ val conversationId = detail.string("conversation_id") ?: return null
+ val isGroup = detail.string("__typename") == "XChatGroupConversationDetail"
+ val participantResults =
+ if (isGroup) {
+ (
+ detail.array("participants_results").orEmpty() +
+ detail.array("group_members_results").orEmpty()
+ ).distinctBy { it.asObjectOrNull()?.string("rest_id") }
+ } else {
+ detail.array("participants_results").orEmpty()
+ }
+ val parsedParticipants =
+ participantResults
+ .mapNotNull { it.asObjectOrNull()?.toXChatUser() }
+ val participants =
+ if (isGroup || viewerId == null) {
+ parsedParticipants
+ } else {
+ val parsedIds = parsedParticipants.map { it.userId }.toSet()
+ parsedParticipants +
+ conversationId
+ .split(":")
+ .filter { it.isNotBlank() && it != viewerId && it !in parsedIds }
+ .map { XChatUser(userId = it, name = null, screenName = null, avatarUrl = null) }
+ }
+ if (!isGroup && viewerId != null && conversationId.isSelfConversation(viewerId, participants)) {
+ return null
+ }
+ if (boolean("is_deleted_by_viewer") == true) {
+ return null
+ }
+ val cKeyMap =
+ if (identity != null) {
+ buildConversationKeyMap(
+ keyChangeB64s = array("latest_conversation_key_change_events").asStringList(),
+ identity = identity,
+ )
+ } else {
+ emptyMap()
+ }
+ identity?.rememberConversationKeys(conversationId, cKeyMap)
+ val latestEvents =
+ (
+ listOfNotNull(string("latest_notifiable_message_create_event")) +
+ listOfNotNull(string("latest_non_notifiable_message_create_event")) +
+ array("latest_message_events").asStringList()
+ ).distinct()
+ .mapNotNull { decodeMessageEvent(it, cKeyMap) }
+ if (latestEvents.latestTimelineEvent()?.kind == XChatDecodedEventKind.ConversationDelete) {
+ return null
+ }
+ val latestMessage = latestEvents.latestDisplayableMessage()
+ return XChatConversation(
+ conversationId = conversationId,
+ type = if (isGroup) XChatConversationType.Group else XChatConversationType.Direct,
+ isMuted = detail.boolean("is_muted"),
+ groupName = detail.obj("group_metadata")?.string("group_name"),
+ groupAvatarUrl = detail.obj("group_metadata")?.string("group_avatar_url"),
+ participants = participants,
+ latestSequenceId = string("latest_message_sequence_id"),
+ latestMessage = latestMessage,
+ unreadCount = unreadCount(viewerId, latestMessage),
+ )
+ }
+
+ private fun JsonObject.unreadCount(
+ viewerId: String?,
+ latestMessage: XChatDecodedEvent?,
+ ): Long {
+ val message = latestMessage ?: return 0
+ if (viewerId == null || message.senderId == viewerId) return 0
+ val latestMessageIsDisplayable = message.kind == XChatDecodedEventKind.Message
+ val latestSequenceId = string("latest_message_sequence_id")?.toULongOrNull() ?: return 0
+ val lastReadSequenceId =
+ array("latest_read_events_per_participant")
+ .orEmpty()
+ .firstNotNullOfOrNull { entry ->
+ val item = entry.asObjectOrNull() ?: return@firstNotNullOfOrNull null
+ val participantId = item.obj("participant_id_results")?.string("rest_id")
+ if (participantId != viewerId) return@firstNotNullOfOrNull null
+ XChatThrift
+ .readLeadingSequenceId(item.string("latest_mark_conversation_read_event"))
+ ?.toULongOrNull()
+ } ?: return if (latestMessageIsDisplayable) 1 else 0
+ return if (latestSequenceId > lastReadSequenceId && latestMessageIsDisplayable) 1 else 0
+ }
+
+ private suspend fun buildConversationKeyMap(
+ keyChangeB64s: List,
+ identity: XChatLoadedIdentity,
+ ): MutableMap {
+ val map = mutableMapOf()
+ keyChangeB64s.forEach { keyChangeB64 ->
+ runCatching {
+ val keyChangeEvent =
+ XChatThrift
+ .parse(XChatBase64.decode(keyChangeB64))
+ .struct(7)
+ ?.struct(3)
+ ?: return@runCatching
+ val version = keyChangeEvent.string(1) ?: return@runCatching
+ keyChangeEvent
+ .list(2)
+ .orEmpty()
+ .mapNotNull { it.asStruct() }
+ .firstOrNull { it.string(1) == identity.userId }
+ ?.string(2)
+ ?.let { wrappedKey ->
+ map[version] = XChatCrypto.eciesUnwrap(wrappedKey, identity.identityPrivateKey)
+ }
+ }
+ }
+ return map
+ }
+
+ private fun XChatLoadedIdentity.rememberConversationKeys(
+ conversationId: String,
+ keys: Map,
+ ) {
+ val latest =
+ keys.maxWithOrNull(
+ compareBy> {
+ it.key.toULongOrNull() ?: ULong.MIN_VALUE
+ }.thenBy { it.key },
+ ) ?: return
+ conversationKeys[conversationId] =
+ XChatConversationKey(
+ key = latest.value,
+ version = latest.key,
+ )
+ }
+
+ internal fun decodeMessageEvent(
+ eventB64: String,
+ cKeyMap: Map,
+ ): XChatDecodedEvent? =
+ runCatching {
+ val event = XChatThrift.parse(XChatBase64.decode(eventB64))
+ val detail = event.struct(7).orEmpty()
+ val conversationToken = event.string(5)
+ val base =
+ XChatDecodedEvent(
+ sequenceId = event.string(1),
+ messageId = event.string(2),
+ senderId = event.string(3),
+ conversationId = event.string(4),
+ conversationToken = conversationToken,
+ createdAtMillis = event.string(6)?.toLongOrNull() ?: event.long(6),
+ kind = detail.kind(),
+ )
+ val messageCreateEvent = detail.struct(1) ?: return@runCatching base.withDeleteTargets(detail)
+ val frame = messageCreateEvent.binary(100)
+ val conversationKeyVersion = messageCreateEvent.string(101)
+ if (frame == null) {
+ return@runCatching base.copy(
+ kind = XChatDecodedEventKind.Message,
+ conversationKeyVersion = conversationKeyVersion,
+ encrypted = conversationKeyVersion != null,
+ )
+ }
+ val plaintext =
+ if (conversationKeyVersion == null) {
+ frame
+ } else {
+ val conversationKey =
+ cKeyMap[conversationKeyVersion]
+ ?: return@runCatching base.copy(
+ kind = XChatDecodedEventKind.Message,
+ conversationKeyVersion = conversationKeyVersion,
+ encrypted = true,
+ )
+ XChatCrypto.decryptBody(
+ frame = frame,
+ conversationKey = conversationKey,
+ ) ?: return@runCatching base.copy(
+ kind = XChatDecodedEventKind.Message,
+ conversationKeyVersion = conversationKeyVersion,
+ decryptError = true,
+ )
+ }
+ base
+ .copy(conversationKeyVersion = conversationKeyVersion)
+ .withEntry(XChatThrift.parse(plaintext).struct(1).orEmpty())
+ }.getOrNull()
+
+ private fun XChatDecodedEvent.withDeleteTargets(detail: XChatThriftStruct): XChatDecodedEvent {
+ val deleteEvent = detail.struct(7) ?: return this
+ return copy(
+ targetSequenceIds =
+ deleteEvent
+ .list(1)
+ .orEmpty()
+ .mapNotNull { (it as? XChatThriftValue.Binary)?.bytes?.decodeToString() },
+ )
+ }
+
+ private fun XChatDecodedEvent.withEntry(entry: XChatThriftStruct): XChatDecodedEvent =
+ when {
+ entry.struct(1) != null ->
+ copy(
+ kind = XChatDecodedEventKind.Message,
+ text = entry.struct(1)?.string(1),
+ )
+ entry.struct(2) != null ->
+ copy(
+ kind = XChatDecodedEventKind.ReactionAdd,
+ targetSequenceIds = listOfNotNull(entry.struct(2)?.string(1)),
+ emoji = entry.struct(2)?.string(2),
+ )
+ entry.struct(3) != null ->
+ copy(
+ kind = XChatDecodedEventKind.ReactionRemove,
+ targetSequenceIds = listOfNotNull(entry.struct(3)?.string(1)),
+ emoji = entry.struct(3)?.string(2),
+ )
+ entry.struct(4) != null ->
+ copy(
+ kind = XChatDecodedEventKind.Edit,
+ targetSequenceIds = listOfNotNull(entry.struct(4)?.string(1)),
+ text = entry.struct(4)?.string(2),
+ )
+ entry.struct(5) != null ->
+ copy(
+ kind = XChatDecodedEventKind.Read,
+ targetSequenceIds = listOfNotNull(entry.struct(5)?.string(1)),
+ )
+ entry.struct(6) != null ->
+ copy(
+ kind = XChatDecodedEventKind.Unread,
+ targetSequenceIds = listOfNotNull(entry.struct(6)?.string(1)),
+ )
+ entry.struct(10) != null ->
+ copy(
+ kind = XChatDecodedEventKind.AvCallEnded,
+ targetSequenceIds = emptyList(),
+ )
+ entry.struct(11) != null ->
+ copy(kind = XChatDecodedEventKind.AvCallMissed)
+ entry.struct(16) != null ->
+ copy(kind = XChatDecodedEventKind.AvCallStarted)
+ else -> this
+ }
+
+ private fun XChatThriftStruct.kind(): XChatDecodedEventKind =
+ when {
+ struct(1) != null -> XChatDecodedEventKind.Message
+ struct(3) != null -> XChatDecodedEventKind.ConversationKeyChange
+ struct(6) != null -> XChatDecodedEventKind.Typing
+ struct(7) != null -> XChatDecodedEventKind.Delete
+ struct(8) != null -> XChatDecodedEventKind.ConversationDelete
+ struct(9) != null -> XChatDecodedEventKind.MetadataChange
+ struct(12) != null -> XChatDecodedEventKind.Read
+ struct(13) != null -> XChatDecodedEventKind.Unread
+ else -> XChatDecodedEventKind.Unknown
+ }
+
+ private fun List.latestDisplayableMessage(): XChatDecodedEvent? =
+ filter { it.hasRenderableContent() }
+ .latestTimelineEvent()
+ ?: filter { it.isEncryptedPlaceholder() }
+ .latestTimelineEvent()
+
+ private fun List.latestTimelineEvent(): XChatDecodedEvent? =
+ maxWithOrNull(
+ compareBy {
+ it.sequenceId?.toULongOrNull() ?: ULong.MIN_VALUE
+ }.thenBy {
+ it.createdAtMillis ?: Long.MIN_VALUE
+ },
+ )
+
+ private fun XChatDecodedEvent.hasRenderableContent(): Boolean =
+ when (kind) {
+ XChatDecodedEventKind.Message,
+ XChatDecodedEventKind.Edit,
+ -> !text.isNullOrBlank()
+ XChatDecodedEventKind.Delete -> true
+ else -> false
+ }
+
+ private fun XChatDecodedEvent.isEncryptedPlaceholder(): Boolean =
+ when (kind) {
+ XChatDecodedEventKind.Message,
+ XChatDecodedEventKind.Edit,
+ -> text.isNullOrBlank() && (encrypted || decryptError)
+ else -> false
+ }
+
+ private fun String.isSelfConversation(
+ viewerId: String,
+ participants: List,
+ ): Boolean {
+ val ids = split(":").filter { it.isNotBlank() } + participants.map { it.userId }
+ return ids.isNotEmpty() && ids.all { it == viewerId }
+ }
+
+ private fun initialPageQuerySettings(): JsonObject =
+ buildJsonObject {
+ put("conversation_event_limit", 200)
+ put("inbox_conversation_event_limit", 20)
+ put("inbox_conversation_limit", 20)
+ put("user_event_limit", 500)
+ }
+
+ companion object {
+ const val MAX_SEQUENCE_ID: String = "9223372036854775807"
+ const val DEFAULT_INITIAL_MESSAGE_PULL_VERSION: Int = 1761251295
+
+ fun conversationId1on1(
+ a: String,
+ b: String,
+ ): String {
+ val first = a.toULongOrNull()
+ val second = b.toULongOrNull()
+ val values =
+ if (first != null && second != null) {
+ listOf(first to a, second to b).sortedBy { it.first }.map { it.second }
+ } else {
+ listOf(a, b).sorted()
+ }
+ return values.joinToString(":")
+ }
+ }
+}
+
+internal data class XChatInboxPage(
+ val conversations: List,
+ val nextKey: String?,
+ val hasMore: Boolean,
+ val messageRequestsCount: Int?,
+ val messagePullVersion: Int?,
+ val maxUserSequenceId: String?,
+)
+
+internal data class XChatConversationPage(
+ val messages: List,
+ val hasMore: Boolean,
+ val nextKey: String?,
+)
+
+internal data class XChatSendResult(
+ val conversationId: String,
+ val messageId: String,
+ val sequenceId: String?,
+ val encodedMessageEvent: String?,
+ val decodedEvent: XChatDecodedEvent?,
+)
+
+internal data class XChatConversation(
+ val conversationId: String,
+ val type: XChatConversationType,
+ val isMuted: Boolean?,
+ val groupName: String?,
+ val groupAvatarUrl: String?,
+ val participants: List,
+ val latestSequenceId: String?,
+ val latestMessage: XChatDecodedEvent?,
+ val unreadCount: Long,
+)
+
+internal enum class XChatConversationType {
+ Direct,
+ Group,
+}
+
+internal data class XChatUser(
+ val userId: String,
+ val name: String?,
+ val screenName: String?,
+ val avatarUrl: String?,
+)
+
+internal data class XChatDecodedEvent(
+ val sequenceId: String?,
+ val messageId: String?,
+ val senderId: String?,
+ val conversationId: String?,
+ val conversationToken: String? = null,
+ val createdAtMillis: Long?,
+ val kind: XChatDecodedEventKind,
+ val text: String? = null,
+ val conversationKeyVersion: String? = null,
+ val targetSequenceIds: List = emptyList(),
+ val emoji: String? = null,
+ val encrypted: Boolean = false,
+ val decryptError: Boolean = false,
+)
+
+internal enum class XChatDecodedEventKind {
+ Message,
+ ConversationKeyChange,
+ Typing,
+ Delete,
+ ConversationDelete,
+ MetadataChange,
+ Read,
+ Unread,
+ ReactionAdd,
+ ReactionRemove,
+ Edit,
+ AvCallStarted,
+ AvCallEnded,
+ AvCallMissed,
+ Unknown,
+}
+
+internal data class XChatUserPublicKeys(
+ val userId: String?,
+ val onXChat: Boolean,
+ val permissions: XChatPermissions?,
+ val keys: List,
+)
+
+internal data class XChatPermissions(
+ val canDm: Boolean?,
+ val canDmOnXChat: Boolean?,
+ val dmBlocking: Boolean?,
+ val passesPremiumCheck: Boolean?,
+)
+
+internal data class XChatCallPermission(
+ val canDm: Boolean?,
+ val errorCode: String?,
+)
+
+internal data class XChatPublicKey(
+ val version: String?,
+ val publicKey: String?,
+ val signingPublicKey: String?,
+ val identityPublicKeySignature: String?,
+ val registrationMethod: String?,
+ val tokenMap: XChatJuiceboxTokenMap?,
+ val targetTokenMap: XChatJuiceboxTokenMap?,
+)
+
+internal data class XChatJuiceboxTokenMap(
+ val realmState: String?,
+ val realmStateString: String?,
+ val maxGuessCount: Int?,
+ val recoverThreshold: Int?,
+ val registerThreshold: Int?,
+ val keyStoreTokenMapJson: String?,
+ val entries: List,
+)
+
+internal data class XChatJuiceboxTokenEntry(
+ val realmId: String,
+ val token: String,
+ val address: String,
+ val publicKey: String?,
+)
+
+private data class XChatRecipientKey(
+ val userId: String,
+ val publicKeySpki: ByteArray,
+ val version: String,
+)
+
+private fun JsonObject.toUserPublicKeys(): XChatUserPublicKeys {
+ val result = obj("result")
+ val publicKeysResult = result?.obj("get_public_keys")
+ val keys =
+ publicKeysResult
+ ?.array("public_keys_with_token_map")
+ .orEmpty()
+ .mapNotNull { entry ->
+ val entryObject = entry.asObjectOrNull() ?: return@mapNotNull null
+ val metadata = entryObject.obj("public_key_with_metadata") ?: return@mapNotNull null
+ val publicKey = metadata.obj("public_key")
+ XChatPublicKey(
+ version = metadata.string("version"),
+ publicKey = publicKey?.string("public_key"),
+ signingPublicKey = publicKey?.string("signing_public_key"),
+ identityPublicKeySignature = publicKey?.string("identity_public_key_signature"),
+ registrationMethod = publicKey?.string("registration_method"),
+ tokenMap = entryObject.obj("token_map")?.toJuiceboxTokenMap(),
+ targetTokenMap = entryObject.obj("target_token_map")?.toJuiceboxTokenMap(),
+ )
+ }
+ return XChatUserPublicKeys(
+ userId = string("rest_id"),
+ onXChat = keys.isNotEmpty(),
+ permissions = result?.obj("chat_permissions")?.toPermissions(),
+ keys = keys,
+ )
+}
+
+private fun JsonObject.toPermissions(): XChatPermissions =
+ XChatPermissions(
+ canDm = boolean("can_dm"),
+ canDmOnXChat = boolean("can_dm_on_xchat"),
+ dmBlocking = boolean("dm_blocking"),
+ passesPremiumCheck = boolean("passes_premium_check"),
+ )
+
+private fun JsonObject.toCallPermission(): XChatCallPermission =
+ XChatCallPermission(
+ canDm = boolean("can_dm"),
+ errorCode = string("error_code"),
+ )
+
+private fun JsonObject.toJuiceboxTokenMap(): XChatJuiceboxTokenMap =
+ XChatJuiceboxTokenMap(
+ realmState = string("realm_state"),
+ realmStateString = string("realm_state_string"),
+ maxGuessCount = int("max_guess_count"),
+ recoverThreshold = int("recover_threshold"),
+ registerThreshold = int("register_threshold"),
+ keyStoreTokenMapJson = string("key_store_token_map_json"),
+ entries =
+ array("token_map")
+ .orEmpty()
+ .mapNotNull { entry ->
+ val entryObject = entry.asObjectOrNull() ?: return@mapNotNull null
+ val value = entryObject.obj("value") ?: return@mapNotNull null
+ XChatJuiceboxTokenEntry(
+ realmId = entryObject.string("key") ?: return@mapNotNull null,
+ token = value.string("token") ?: return@mapNotNull null,
+ address = value.string("address") ?: return@mapNotNull null,
+ publicKey = value.string("public_key"),
+ )
+ },
+ )
+
+private fun JsonObject.toXChatUser(): XChatUser? {
+ val userId = string("rest_id") ?: return null
+ val result = obj("result")
+ return XChatUser(
+ userId = userId,
+ name = result?.obj("core")?.string("name"),
+ screenName = result?.obj("core")?.string("screen_name"),
+ avatarUrl = result?.obj("avatar")?.string("image_url"),
+ )
+}
+
+private fun List.minSequenceId(): String? =
+ mapNotNull { it.sequenceId?.toULongOrNull() }
+ .minOrNull()
+ ?.toString()
+
+private fun JsonArray?.asStringList(): List =
+ this
+ .orEmpty()
+ .mapNotNull { it.jsonPrimitive.contentOrNull }
+
+private fun JsonObject.obj(name: String): JsonObject? = get(name).asObjectOrNull()
+
+private fun JsonObject.array(name: String): JsonArray? = get(name) as? JsonArray
+
+private fun JsonObject.string(name: String): String? =
+ get(name)
+ ?.jsonPrimitive
+ ?.contentOrNull
+
+private fun JsonObject.boolean(name: String): Boolean? =
+ get(name)
+ ?.jsonPrimitive
+ ?.booleanOrNull
+
+private fun JsonObject.int(name: String): Int? =
+ get(name)
+ ?.jsonPrimitive
+ ?.intOrNull
+
+private fun JsonObject.long(name: String): Long? =
+ get(name)
+ ?.jsonPrimitive
+ ?.longOrNull
+
+private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatThrift.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatThrift.kt
new file mode 100644
index 0000000000..26086e0482
--- /dev/null
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatThrift.kt
@@ -0,0 +1,411 @@
+package dev.dimension.flare.data.network.xqt.xchat
+
+internal typealias XChatThriftStruct = Map
+
+internal sealed interface XChatThriftValue {
+ data class Bool(
+ val value: Boolean,
+ ) : XChatThriftValue
+
+ data class I32(
+ val value: Int,
+ ) : XChatThriftValue
+
+ data class I64(
+ val value: Long,
+ ) : XChatThriftValue
+
+ data class Binary(
+ val bytes: ByteArray,
+ ) : XChatThriftValue
+
+ data class Struct(
+ val fields: XChatThriftStruct,
+ ) : XChatThriftValue
+
+ data class ListValue(
+ val values: List,
+ ) : XChatThriftValue
+}
+
+internal object XChatThrift {
+ fun parse(bytes: ByteArray): XChatThriftStruct = readStruct(bytes, 0).first
+
+ fun readLeadingSequenceId(base64: String?): String? =
+ runCatching {
+ val bytes = XChatBase64.decode(base64 ?: return null)
+ if (bytes.size < 7 || bytes[0].u() != T_STRING || bytes[1].u() != 0 || bytes[2].u() != 1) {
+ return null
+ }
+ val length = readI32(bytes, 3)
+ bytes.copyOfRange(7, 7 + length).decodeToString()
+ }.getOrNull()
+
+ fun readStringField(
+ base64: String?,
+ fieldId: Int,
+ ): String? =
+ runCatching {
+ val bytes = XChatBase64.decode(base64 ?: return null)
+ var offset = 0
+ while (offset < bytes.size) {
+ val type = bytes[offset++].u()
+ if (type == T_STOP) break
+ val id = (bytes[offset++].u() shl 8) or bytes[offset++].u()
+ if (type == T_STRING) {
+ val length = readI32(bytes, offset)
+ offset += 4
+ val value = bytes.copyOfRange(offset, offset + length).decodeToString()
+ offset += length
+ if (id == fieldId) return value
+ } else {
+ offset = skipValue(bytes, offset, type)
+ }
+ }
+ null
+ }.getOrNull()
+
+ fun textHolder(text: String): ByteArray =
+ holder(
+ variantId = 1,
+ structBytes = fStr(1, text) + STOP,
+ )
+
+ fun reactionAddHolder(
+ sequenceId: String,
+ emoji: String,
+ attachmentId: String? = null,
+ ): ByteArray =
+ reactionHolder(
+ variantId = 2,
+ sequenceId = sequenceId,
+ emoji = emoji,
+ attachmentId = attachmentId,
+ )
+
+ fun reactionRemoveHolder(
+ sequenceId: String,
+ emoji: String,
+ attachmentId: String? = null,
+ ): ByteArray =
+ reactionHolder(
+ variantId = 3,
+ sequenceId = sequenceId,
+ emoji = emoji,
+ attachmentId = attachmentId,
+ )
+
+ fun editHolder(
+ sequenceId: String,
+ updatedText: String,
+ ): ByteArray =
+ holder(
+ variantId = 4,
+ structBytes = fStr(1, sequenceId) + fStr(2, updatedText) + STOP,
+ )
+
+ fun markReadHolder(
+ sequenceId: String,
+ seenAtMillis: Long,
+ ): ByteArray =
+ holder(
+ variantId = 5,
+ structBytes = fStr(1, sequenceId) + fI64(2, seenAtMillis) + STOP,
+ )
+
+ fun markUnreadHolder(sequenceId: String): ByteArray =
+ holder(
+ variantId = 6,
+ structBytes = fStr(1, sequenceId) + STOP,
+ )
+
+ fun pinHolder(conversationId: String): ByteArray =
+ holder(
+ variantId = 7,
+ structBytes = fStr(1, conversationId) + STOP,
+ )
+
+ fun unpinHolder(conversationId: String): ByteArray =
+ holder(
+ variantId = 8,
+ structBytes = fStr(1, conversationId) + STOP,
+ )
+
+ fun screenCaptureHolder(type: ScreenCaptureType): ByteArray =
+ holder(
+ variantId = 9,
+ structBytes = fI32(1, type.value) + STOP,
+ )
+
+ fun nicknameHolder(
+ userId: String,
+ nickname: String,
+ ): ByteArray =
+ holder(
+ variantId = 14,
+ structBytes = fI64(1, userId.toLong()) + fStr(2, nickname) + STOP,
+ )
+
+ fun messageCreateEvent(
+ frame: ByteArray,
+ conversationKeyVersion: String,
+ shouldNotify: Boolean = true,
+ ttlMsec: Long? = null,
+ ): ByteArray =
+ fBin(100, frame) +
+ fStr(101, conversationKeyVersion) +
+ fBool(102, shouldNotify) +
+ (ttlMsec?.let { fI64(103, it) } ?: ByteArray(0)) +
+ STOP
+
+ fun eventSignature(
+ signatureBase64Url: String,
+ publicKeyVersion: String,
+ signingPublicKeyB64: String,
+ senderId: String,
+ ): ByteArray {
+ val keyInfo =
+ fStr(1, senderId) +
+ fStr(2, publicKeyVersion) +
+ fStr(3, signingPublicKeyB64) +
+ STOP
+ return fStr(1, signatureBase64Url) +
+ fStr(2, publicKeyVersion) +
+ fStr(3, "7") +
+ fStr(4, signingPublicKeyB64) +
+ listOfStructs(5, listOf(keyInfo)) +
+ STOP
+ }
+
+ fun deleteEventDetail(
+ sequenceIds: List,
+ actionValue: Int,
+ ): ByteArray {
+ val deleteEvent = listOfStrings(1, sequenceIds) + fI32(2, actionValue) + STOP
+ return fStruct(7, deleteEvent) + STOP
+ }
+
+ private fun holder(
+ variantId: Int,
+ structBytes: ByteArray,
+ ): ByteArray {
+ val entryContents = fStruct(variantId, structBytes) + STOP
+ return fStruct(1, entryContents) + STOP
+ }
+
+ private fun reactionHolder(
+ variantId: Int,
+ sequenceId: String,
+ emoji: String,
+ attachmentId: String?,
+ ): ByteArray =
+ holder(
+ variantId = variantId,
+ structBytes =
+ fStr(1, sequenceId) +
+ fStr(2, emoji) +
+ (attachmentId?.let { fStr(3, it) } ?: ByteArray(0)) +
+ STOP,
+ )
+
+ private fun readStruct(
+ bytes: ByteArray,
+ start: Int,
+ ): Pair {
+ val fields = mutableMapOf()
+ var offset = start
+ while (offset < bytes.size) {
+ val type = bytes[offset++].u()
+ if (type == T_STOP) break
+ val fieldId = (bytes[offset++].u() shl 8) or bytes[offset++].u()
+ val (value, nextOffset) = readValue(bytes, offset, type)
+ fields[fieldId] = value
+ offset = nextOffset
+ }
+ return fields to offset
+ }
+
+ private fun readValue(
+ bytes: ByteArray,
+ offset: Int,
+ type: Int,
+ ): Pair =
+ when (type) {
+ T_BOOL -> XChatThriftValue.Bool(bytes[offset].u() != 0) to offset + 1
+ T_I32 -> XChatThriftValue.I32(readI32(bytes, offset)) to offset + 4
+ T_I64 -> XChatThriftValue.I64(readI64(bytes, offset)) to offset + 8
+ T_STRING -> {
+ val length = readI32(bytes, offset)
+ val start = offset + 4
+ XChatThriftValue.Binary(bytes.copyOfRange(start, start + length)) to start + length
+ }
+ T_STRUCT -> {
+ val (struct, nextOffset) = readStruct(bytes, offset)
+ XChatThriftValue.Struct(struct) to nextOffset
+ }
+ T_LIST -> {
+ val elementType = bytes[offset].u()
+ val count = readI32(bytes, offset + 1)
+ var currentOffset = offset + 5
+ val values = buildList {
+ repeat(count) {
+ val (value, nextOffset) = readValue(bytes, currentOffset, elementType)
+ add(value)
+ currentOffset = nextOffset
+ }
+ }
+ XChatThriftValue.ListValue(values) to currentOffset
+ }
+ else -> error("Unknown thrift type $type at $offset")
+ }
+
+ private fun skipValue(
+ bytes: ByteArray,
+ offset: Int,
+ type: Int,
+ ): Int =
+ when (type) {
+ T_BOOL -> offset + 1
+ T_I32 -> offset + 4
+ T_I64 -> offset + 8
+ T_STRING -> offset + 4 + readI32(bytes, offset)
+ T_STRUCT -> readStruct(bytes, offset).second
+ T_LIST -> {
+ val elementType = bytes[offset].u()
+ val count = readI32(bytes, offset + 1)
+ var currentOffset = offset + 5
+ repeat(count) {
+ currentOffset = skipValue(bytes, currentOffset, elementType)
+ }
+ currentOffset
+ }
+ else -> error("Unknown thrift type $type at $offset")
+ }
+
+ private fun fStr(
+ id: Int,
+ value: String,
+ ): ByteArray = fBin(id, value.encodeToByteArray())
+
+ private fun fBin(
+ id: Int,
+ value: ByteArray,
+ ): ByteArray = fieldHeader(T_STRING, id) + i32be(value.size) + value
+
+ private fun fBool(
+ id: Int,
+ value: Boolean,
+ ): ByteArray = fieldHeader(T_BOOL, id) + byteArrayOf((if (value) 1 else 0).toByte())
+
+ private fun fI32(
+ id: Int,
+ value: Int,
+ ): ByteArray = fieldHeader(T_I32, id) + i32be(value)
+
+ private fun fI64(
+ id: Int,
+ value: Long,
+ ): ByteArray = fieldHeader(T_I64, id) + i64be(value)
+
+ private fun fStruct(
+ id: Int,
+ bytes: ByteArray,
+ ): ByteArray = fieldHeader(T_STRUCT, id) + bytes
+
+ private fun listOfStructs(
+ id: Int,
+ structs: List,
+ ): ByteArray =
+ fieldHeader(T_LIST, id) +
+ byteArrayOf(T_STRUCT.toByte()) +
+ i32be(structs.size) +
+ structs.fold(ByteArray(0)) { acc, bytes -> acc + bytes }
+
+ private fun listOfStrings(
+ id: Int,
+ values: List,
+ ): ByteArray =
+ fieldHeader(T_LIST, id) +
+ byteArrayOf(T_STRING.toByte()) +
+ i32be(values.size) +
+ values.fold(ByteArray(0)) { acc, value ->
+ val bytes = value.encodeToByteArray()
+ acc + i32be(bytes.size) + bytes
+ }
+
+ private fun fieldHeader(
+ type: Int,
+ id: Int,
+ ): ByteArray = byteArrayOf(type.toByte(), (id ushr 8).toByte(), id.toByte())
+
+ private fun i32be(value: Int): ByteArray =
+ byteArrayOf(
+ (value ushr 24).toByte(),
+ (value ushr 16).toByte(),
+ (value ushr 8).toByte(),
+ value.toByte(),
+ )
+
+ private fun i64be(value: Long): ByteArray =
+ ByteArray(8) { index ->
+ (value ushr ((7 - index) * 8)).toByte()
+ }
+
+ private fun readI32(
+ bytes: ByteArray,
+ offset: Int,
+ ): Int =
+ (bytes[offset].u() shl 24) or
+ (bytes[offset + 1].u() shl 16) or
+ (bytes[offset + 2].u() shl 8) or
+ bytes[offset + 3].u()
+
+ private fun readI64(
+ bytes: ByteArray,
+ offset: Int,
+ ): Long {
+ var value = 0L
+ repeat(8) { index ->
+ value = (value shl 8) or bytes[offset + index].u().toLong()
+ }
+ return value
+ }
+
+ private fun Byte.u(): Int = toInt() and 0xff
+
+ private const val T_STOP = 0
+ private const val T_BOOL = 2
+ private const val T_I32 = 8
+ private const val T_I64 = 10
+ private const val T_STRING = 11
+ private const val T_STRUCT = 12
+ private const val T_LIST = 15
+ private val STOP = byteArrayOf(T_STOP.toByte())
+}
+
+internal enum class ScreenCaptureType(
+ val value: Int,
+) {
+ Unknown(0),
+ Screenshot(1),
+ Recording(2),
+}
+
+internal fun XChatThriftStruct.struct(id: Int): XChatThriftStruct? =
+ (this[id] as? XChatThriftValue.Struct)?.fields
+
+internal fun XChatThriftStruct.list(id: Int): List? =
+ (this[id] as? XChatThriftValue.ListValue)?.values
+
+internal fun XChatThriftStruct.binary(id: Int): ByteArray? =
+ (this[id] as? XChatThriftValue.Binary)?.bytes
+
+internal fun XChatThriftStruct.string(id: Int): String? = binary(id)?.decodeToString()
+
+internal fun XChatThriftStruct.long(id: Int): Long? = (this[id] as? XChatThriftValue.I64)?.value
+
+internal fun XChatThriftStruct.int(id: Int): Int? = (this[id] as? XChatThriftValue.I32)?.value
+
+internal fun XChatThriftStruct.boolean(id: Int): Boolean? = (this[id] as? XChatThriftValue.Bool)?.value
+
+internal fun XChatThriftValue.asStruct(): XChatThriftStruct? = (this as? XChatThriftValue.Struct)?.fields
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XQTCredential.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XQTCredential.kt
index bb9cd318d0..51b287f835 100644
--- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XQTCredential.kt
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XQTCredential.kt
@@ -1,12 +1,34 @@
package dev.dimension.flare.data.platform
import androidx.compose.runtime.Immutable
+import dev.dimension.flare.common.JSON
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonObject
@Immutable
@Serializable
@SerialName("XQTCredential")
internal data class XQTCredential(
val chocolate: String,
+ val xchatIdentity: XChatIdentityCredential? = null,
)
+
+@Immutable
+@Serializable
+internal data class XChatIdentityCredential(
+ val userId: String,
+ val version: String,
+ val publicKeyB64: String,
+ val signingPublicKeyB64: String,
+ val identityPrivateJwk: JsonObject? = null,
+ val signingPrivateJwk: JsonObject? = null,
+ val registrationMethod: String? = null,
+ val pinBacked: Boolean? = null,
+)
+
+internal fun XChatIdentityCredential.toJsonString(): String =
+ JSON.encodeToString(
+ XChatIdentityCredential.serializer(),
+ this,
+ )
diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XqtPlatformSpec.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XqtPlatformSpec.kt
index d36f8595f9..979d3e5db5 100644
--- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XqtPlatformSpec.kt
+++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XqtPlatformSpec.kt
@@ -118,6 +118,12 @@ public data object XqtPlatformSpec :
XQTDataSource(
accountKey = context.accountKey,
sourceCredentialFlow = context.credentialFlow(XQTCredential.serializer()),
+ updateCredential = {
+ context.updateCredential(
+ serializer = XQTCredential.serializer(),
+ credential = it,
+ )
+ },
)
override fun guestDataSource(