Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/expo-native-custom-pages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
---
'@clerk/expo': minor
---

Add custom user profile pages to the native `UserProfileView` and `UserButton` components. Use `content` to render a React Native screen or `href` to open an external URL.

```tsx
import { UserButton, UserProfileView } from '@clerk/expo/native';
import type { UserProfileCustomPage } from '@clerk/expo/native';

const customPages: UserProfileCustomPage[] = [
{
path: 'api-keys',
label: 'API keys',
icon: 'key',
content: <APIKeysView />,
},
{
path: 'docs',
label: 'Docs',
icon: 'book',
href: 'https://clerk.com/docs',
},
];

<UserProfileView customPages={customPages} />;

<UserButton userProfileProps={{ customPages }} />;
```
26 changes: 26 additions & 0 deletions integration/templates/expo-native/App.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,22 @@ function NativeBuildFixture() {
<View style={styles.container}>
<View style={styles.embeddedProfile}>
<UserProfileView
customPages={[
{
path: 'e2e-custom-page',
label: 'E2E Custom Page',
icon: 'key',
placement: { type: 'after', row: 'security' },
content: (
<View
testID='custom-page-content'
style={styles.customPage}
>
<Text style={styles.customPageText}>Rehosted RN body</Text>
</View>
),
},
]}
isDismissible={false}
onHostBack={() => setIsProfileOpen(false)}
/>
Expand DownExpand Up@@ -120,6 +136,16 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
customPage: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 24,
},
customPageText: {
fontSize: 16,
fontWeight: '600',
},
header: {
alignItems: 'center',
flexDirection: 'row',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
# A custom page's React Native content is mounted as a child of the native host
# and rehosted into the destination its row pushes, then survives the trip back.
appId: com.clerk.exponativebuildfixture
name: UserProfileView renders a custom page
---
- runFlow: subflows/open-app.yaml
- tapOn:
id: 'open-auth-view-button'
- runFlow: subflows/sign-in-email-password.yaml
- runFlow: subflows/assert-signed-in.yaml
- tapOn:
id: 'open-embedded-profile-button'
# Retrying wait: the row list lands a frame after the native profile paints.
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 20000
- tapOn:
text: 'E2E Custom Page'
- extendedWaitUntil:
visible: 'Rehosted RN body'
timeout: 15000
# The Android destination is a bare AndroidView with no back chrome, unlike the
# iOS page which clerk-ios pushes onto its own NavigationStack.
- runFlow:
when:
platform: iOS
commands:
- tapOn: 'Back'
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 15000
# The host chevron firing onHostBack, which reads 'Back' on both platforms.
- tapOn: 'Back'
- extendedWaitUntil:
visible:
id: 'open-embedded-profile-button'
timeout: 15000
- tapOn:
id: 'sign-out-button'
- runFlow: subflows/assert-signed-out.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package expo.modules.clerk

import android.content.Context
import android.content.ContextWrapper
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
Expand DownExpand Up@@ -91,6 +92,21 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte

protected open fun onHostDetachedFromWindow() {}

protected fun layoutAndroidViewHandler(view: View) {
view.post {
val holder = view.parent as? View ?: return@post
val handler = holder.parent as? View ?: return@post
val owner = handler.parent as? View ?: return@post

// AndroidView's handler can enter the hierarchy after its Compose owner was laid out.
handler.measure(
View.MeasureSpec.makeMeasureSpec(owner.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(owner.height, View.MeasureSpec.EXACTLY),
)
handler.layout(0, 0, owner.width, owner.height)
}
}

@Composable
protected abstract fun Content()

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
package expo.modules.clerk

import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.clerk.api.Clerk
import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomRow
import com.clerk.ui.userbutton.UserButton
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher

class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
var customPagesJson: String = "[]"
private val customPageViews = mutableListOf<View>()
private var customNavigator: UserProfileCustomNavigator? = null
private val onCustomPageEvent by EventDispatcher()

init {
activity?.let { Clerk.attachActivity(it) }
}
Expand All@@ -23,15 +37,99 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UserButton(clerkTheme = Clerk.customTheme)
UserButton(
clerkTheme = Clerk.customTheme,
customRows = customRows(),
customDestination =
if (customPageViews.isEmpty()) null
else { routeKey -> CustomPageDestination(routeKey) },
)
}
}

fun addCustomPageView(view: View, index: Int) {
(view.parent as? ViewGroup)?.removeView(view)
customPageViews.add(index.coerceIn(0, customPageViews.size), view)
setupView()
}

fun removeCustomPageView(view: View) {
customPageViews.remove(view)
(view.parent as? ViewGroup)?.removeView(view)
setupView()
}

fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)

fun customPageCount(): Int = customPageViews.size

fun navigateCustomPage(action: String, routeKey: String?) {
when (action) {
"back" -> customNavigator?.navigateBack()
"popToRoot" -> customNavigator?.popToRoot()
"push" -> routeKey?.let { customNavigator?.push(it) }
}
}

@Composable
private fun CustomPageDestination(routeKey: String) {
customNavigator = LocalUserProfileCustomNavigator.current
val rows = customRows()
val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return

LaunchedEffect(routeKey) {
layoutAndroidViewHandler(view)
sendCustomPageEvent("presented", routeKey)
}
DisposableEffect(routeKey) {
onDispose { sendCustomPageEvent("dismissed", routeKey) }
}

AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
(view.parent as? ViewGroup)?.removeView(view)
view
},
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())

private fun sendCustomPageEvent(type: String, path: String) {
onCustomPageEvent(mapOf("type" to type, "path" to path))
}
}

class ClerkUserButtonViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkUserButtonView")

View(ClerkUserButtonNativeView::class) {}
View(ClerkUserButtonNativeView::class) {
Events("onCustomPageEvent")

GroupView<ClerkUserButtonNativeView> {
AddChildView<View> { parent, child, index -> parent.addCustomPageView(child, index) }
GetChildCount { parent -> parent.customPageCount() }
GetChildViewAt<View> { parent, index -> parent.customPageViewAt(index) }
RemoveChildView<View> { parent, child -> parent.removeCustomPageView(child) }
RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
}

AsyncFunction("navigateCustomPage") {
view: ClerkUserButtonNativeView,
action: String,
routeKey: String? -> view.navigateCustomPage(action, routeKey)
}

OnViewDidUpdateProps { view: ClerkUserButtonNativeView ->
view.setupView()
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/expo-native-custom-pages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
---
'@clerk/expo': minor
---

Add custom user profile pages to the native `UserProfileView` and `UserButton` components. Use `content` to render a React Native screen or `href` to open an external URL.

```tsx
import { UserButton, UserProfileView } from '@clerk/expo/native';
import type { UserProfileCustomPage } from '@clerk/expo/native';

const customPages: UserProfileCustomPage[] = [
{
path: 'api-keys',
label: 'API keys',
icon: 'key',
content: <APIKeysView />,
},
{
path: 'docs',
label: 'Docs',
icon: 'book',
href: 'https://clerk.com/docs',
},
];

<UserProfileView customPages={customPages} />;

<UserButton userProfileProps={{ customPages }} />;
```
26 changes: 26 additions & 0 deletions integration/templates/expo-native/App.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,22 @@ function NativeBuildFixture() {
<View style={styles.container}>
<View style={styles.embeddedProfile}>
<UserProfileView
customPages={[
{
path: 'e2e-custom-page',
label: 'E2E Custom Page',
icon: 'key',
placement: { type: 'after', row: 'security' },
content: (
<View
testID='custom-page-content'
style={styles.customPage}
>
<Text style={styles.customPageText}>Rehosted RN body</Text>
</View>
),
},
]}
isDismissible={false}
onHostBack={() => setIsProfileOpen(false)}
/>
Expand DownExpand Up@@ -120,6 +136,16 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
customPage: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 24,
},
customPageText: {
fontSize: 16,
fontWeight: '600',
},
header: {
alignItems: 'center',
flexDirection: 'row',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
# A custom page's React Native content is mounted as a child of the native host
# and rehosted into the destination its row pushes, then survives the trip back.
appId: com.clerk.exponativebuildfixture
name: UserProfileView renders a custom page
---
- runFlow: subflows/open-app.yaml
- tapOn:
id: 'open-auth-view-button'
- runFlow: subflows/sign-in-email-password.yaml
- runFlow: subflows/assert-signed-in.yaml
- tapOn:
id: 'open-embedded-profile-button'
# Retrying wait: the row list lands a frame after the native profile paints.
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 20000
- tapOn:
text: 'E2E Custom Page'
- extendedWaitUntil:
visible: 'Rehosted RN body'
timeout: 15000
# The Android destination is a bare AndroidView with no back chrome, unlike the
# iOS page which clerk-ios pushes onto its own NavigationStack.
- runFlow:
when:
platform: iOS
commands:
- tapOn: 'Back'
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 15000
# The host chevron firing onHostBack, which reads 'Back' on both platforms.
- tapOn: 'Back'
- extendedWaitUntil:
visible:
id: 'open-embedded-profile-button'
timeout: 15000
- tapOn:
id: 'sign-out-button'
- runFlow: subflows/assert-signed-out.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package expo.modules.clerk

import android.content.Context
import android.content.ContextWrapper
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
Expand DownExpand Up@@ -91,6 +92,21 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte

protected open fun onHostDetachedFromWindow() {}

protected fun layoutAndroidViewHandler(view: View) {
view.post {
val holder = view.parent as? View ?: return@post
val handler = holder.parent as? View ?: return@post
val owner = handler.parent as? View ?: return@post

// AndroidView's handler can enter the hierarchy after its Compose owner was laid out.
handler.measure(
View.MeasureSpec.makeMeasureSpec(owner.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(owner.height, View.MeasureSpec.EXACTLY),
)
handler.layout(0, 0, owner.width, owner.height)
}
}

@Composable
protected abstract fun Content()

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
package expo.modules.clerk

import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.clerk.api.Clerk
import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomRow
import com.clerk.ui.userbutton.UserButton
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher

class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
var customPagesJson: String = "[]"
private val customPageViews = mutableListOf<View>()
private var customNavigator: UserProfileCustomNavigator? = null
private val onCustomPageEvent by EventDispatcher()

init {
activity?.let { Clerk.attachActivity(it) }
}
Expand All@@ -23,15 +37,99 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UserButton(clerkTheme = Clerk.customTheme)
UserButton(
clerkTheme = Clerk.customTheme,
customRows = customRows(),
customDestination =
if (customPageViews.isEmpty()) null
else { routeKey -> CustomPageDestination(routeKey) },
)
}
}

fun addCustomPageView(view: View, index: Int) {
(view.parent as? ViewGroup)?.removeView(view)
customPageViews.add(index.coerceIn(0, customPageViews.size), view)
setupView()
}

fun removeCustomPageView(view: View) {
customPageViews.remove(view)
(view.parent as? ViewGroup)?.removeView(view)
setupView()
}

fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)

fun customPageCount(): Int = customPageViews.size

fun navigateCustomPage(action: String, routeKey: String?) {
when (action) {
"back" -> customNavigator?.navigateBack()
"popToRoot" -> customNavigator?.popToRoot()
"push" -> routeKey?.let { customNavigator?.push(it) }
}
}

@Composable
private fun CustomPageDestination(routeKey: String) {
customNavigator = LocalUserProfileCustomNavigator.current
val rows = customRows()
val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return

LaunchedEffect(routeKey) {
layoutAndroidViewHandler(view)
sendCustomPageEvent("presented", routeKey)
}
DisposableEffect(routeKey) {
onDispose { sendCustomPageEvent("dismissed", routeKey) }
}

AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
(view.parent as? ViewGroup)?.removeView(view)
view
},
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())

private fun sendCustomPageEvent(type: String, path: String) {
onCustomPageEvent(mapOf("type" to type, "path" to path))
}
}

class ClerkUserButtonViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkUserButtonView")

View(ClerkUserButtonNativeView::class) {}
View(ClerkUserButtonNativeView::class) {
Events("onCustomPageEvent")

GroupView<ClerkUserButtonNativeView> {
AddChildView<View> { parent, child, index -> parent.addCustomPageView(child, index) }
GetChildCount { parent -> parent.customPageCount() }
GetChildViewAt<View> { parent, index -> parent.customPageViewAt(index) }
RemoveChildView<View> { parent, child -> parent.removeCustomPageView(child) }
RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
}

AsyncFunction("navigateCustomPage") {
view: ClerkUserButtonNativeView,
action: String,
routeKey: String? -> view.navigateCustomPage(action, routeKey)
}

OnViewDidUpdateProps { view: ClerkUserButtonNativeView ->
view.setupView()
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/expo-native-custom-pages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
---
'@clerk/expo': minor
---

Add custom user profile pages to the native `UserProfileView` and `UserButton` components. Use `content` to render a React Native screen or `href` to open an external URL.

```tsx
import { UserButton, UserProfileView } from '@clerk/expo/native';
import type { UserProfileCustomPage } from '@clerk/expo/native';

const customPages: UserProfileCustomPage[] = [
{
path: 'api-keys',
label: 'API keys',
icon: 'key',
content: <APIKeysView />,
},
{
path: 'docs',
label: 'Docs',
icon: 'book',
href: 'https://clerk.com/docs',
},
];

<UserProfileView customPages={customPages} />;

<UserButton userProfileProps={{ customPages }} />;
```
26 changes: 26 additions & 0 deletions integration/templates/expo-native/App.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,22 @@ function NativeBuildFixture() {
<View style={styles.container}>
<View style={styles.embeddedProfile}>
<UserProfileView
customPages={[
{
path: 'e2e-custom-page',
label: 'E2E Custom Page',
icon: 'key',
placement: { type: 'after', row: 'security' },
content: (
<View
testID='custom-page-content'
style={styles.customPage}
>
<Text style={styles.customPageText}>Rehosted RN body</Text>
</View>
),
},
]}
isDismissible={false}
onHostBack={() => setIsProfileOpen(false)}
/>
Expand DownExpand Up@@ -120,6 +136,16 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
customPage: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 24,
},
customPageText: {
fontSize: 16,
fontWeight: '600',
},
header: {
alignItems: 'center',
flexDirection: 'row',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
# A custom page's React Native content is mounted as a child of the native host
# and rehosted into the destination its row pushes, then survives the trip back.
appId: com.clerk.exponativebuildfixture
name: UserProfileView renders a custom page
---
- runFlow: subflows/open-app.yaml
- tapOn:
id: 'open-auth-view-button'
- runFlow: subflows/sign-in-email-password.yaml
- runFlow: subflows/assert-signed-in.yaml
- tapOn:
id: 'open-embedded-profile-button'
# Retrying wait: the row list lands a frame after the native profile paints.
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 20000
- tapOn:
text: 'E2E Custom Page'
- extendedWaitUntil:
visible: 'Rehosted RN body'
timeout: 15000
# The Android destination is a bare AndroidView with no back chrome, unlike the
# iOS page which clerk-ios pushes onto its own NavigationStack.
- runFlow:
when:
platform: iOS
commands:
- tapOn: 'Back'
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 15000
# The host chevron firing onHostBack, which reads 'Back' on both platforms.
- tapOn: 'Back'
- extendedWaitUntil:
visible:
id: 'open-embedded-profile-button'
timeout: 15000
- tapOn:
id: 'sign-out-button'
- runFlow: subflows/assert-signed-out.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package expo.modules.clerk

import android.content.Context
import android.content.ContextWrapper
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
Expand DownExpand Up@@ -91,6 +92,21 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte

protected open fun onHostDetachedFromWindow() {}

protected fun layoutAndroidViewHandler(view: View) {
view.post {
val holder = view.parent as? View ?: return@post
val handler = holder.parent as? View ?: return@post
val owner = handler.parent as? View ?: return@post

// AndroidView's handler can enter the hierarchy after its Compose owner was laid out.
handler.measure(
View.MeasureSpec.makeMeasureSpec(owner.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(owner.height, View.MeasureSpec.EXACTLY),
)
handler.layout(0, 0, owner.width, owner.height)
}
}

@Composable
protected abstract fun Content()

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
package expo.modules.clerk

import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.clerk.api.Clerk
import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomRow
import com.clerk.ui.userbutton.UserButton
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher

class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
var customPagesJson: String = "[]"
private val customPageViews = mutableListOf<View>()
private var customNavigator: UserProfileCustomNavigator? = null
private val onCustomPageEvent by EventDispatcher()

init {
activity?.let { Clerk.attachActivity(it) }
}
Expand All@@ -23,15 +37,99 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UserButton(clerkTheme = Clerk.customTheme)
UserButton(
clerkTheme = Clerk.customTheme,
customRows = customRows(),
customDestination =
if (customPageViews.isEmpty()) null
else { routeKey -> CustomPageDestination(routeKey) },
)
}
}

fun addCustomPageView(view: View, index: Int) {
(view.parent as? ViewGroup)?.removeView(view)
customPageViews.add(index.coerceIn(0, customPageViews.size), view)
setupView()
}

fun removeCustomPageView(view: View) {
customPageViews.remove(view)
(view.parent as? ViewGroup)?.removeView(view)
setupView()
}

fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)

fun customPageCount(): Int = customPageViews.size

fun navigateCustomPage(action: String, routeKey: String?) {
when (action) {
"back" -> customNavigator?.navigateBack()
"popToRoot" -> customNavigator?.popToRoot()
"push" -> routeKey?.let { customNavigator?.push(it) }
}
}

@Composable
private fun CustomPageDestination(routeKey: String) {
customNavigator = LocalUserProfileCustomNavigator.current
val rows = customRows()
val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return

LaunchedEffect(routeKey) {
layoutAndroidViewHandler(view)
sendCustomPageEvent("presented", routeKey)
}
DisposableEffect(routeKey) {
onDispose { sendCustomPageEvent("dismissed", routeKey) }
}

AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
(view.parent as? ViewGroup)?.removeView(view)
view
},
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())

private fun sendCustomPageEvent(type: String, path: String) {
onCustomPageEvent(mapOf("type" to type, "path" to path))
}
}

class ClerkUserButtonViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkUserButtonView")

View(ClerkUserButtonNativeView::class) {}
View(ClerkUserButtonNativeView::class) {
Events("onCustomPageEvent")

GroupView<ClerkUserButtonNativeView> {
AddChildView<View> { parent, child, index -> parent.addCustomPageView(child, index) }
GetChildCount { parent -> parent.customPageCount() }
GetChildViewAt<View> { parent, index -> parent.customPageViewAt(index) }
RemoveChildView<View> { parent, child -> parent.removeCustomPageView(child) }
RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
}

AsyncFunction("navigateCustomPage") {
view: ClerkUserButtonNativeView,
action: String,
routeKey: String? -> view.navigateCustomPage(action, routeKey)
}

OnViewDidUpdateProps { view: ClerkUserButtonNativeView ->
view.setupView()
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/expo-native-custom-pages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
---
'@clerk/expo': minor
---

Add custom user profile pages to the native `UserProfileView` and `UserButton` components. Use `content` to render a React Native screen or `href` to open an external URL.

```tsx
import { UserButton, UserProfileView } from '@clerk/expo/native';
import type { UserProfileCustomPage } from '@clerk/expo/native';

const customPages: UserProfileCustomPage[] = [
{
path: 'api-keys',
label: 'API keys',
icon: 'key',
content: <APIKeysView />,
},
{
path: 'docs',
label: 'Docs',
icon: 'book',
href: 'https://clerk.com/docs',
},
];

<UserProfileView customPages={customPages} />;

<UserButton userProfileProps={{ customPages }} />;
```
26 changes: 26 additions & 0 deletions integration/templates/expo-native/App.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,22 @@ function NativeBuildFixture() {
<View style={styles.container}>
<View style={styles.embeddedProfile}>
<UserProfileView
customPages={[
{
path: 'e2e-custom-page',
label: 'E2E Custom Page',
icon: 'key',
placement: { type: 'after', row: 'security' },
content: (
<View
testID='custom-page-content'
style={styles.customPage}
>
<Text style={styles.customPageText}>Rehosted RN body</Text>
</View>
),
},
]}
isDismissible={false}
onHostBack={() => setIsProfileOpen(false)}
/>
Expand DownExpand Up@@ -120,6 +136,16 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
customPage: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 24,
},
customPageText: {
fontSize: 16,
fontWeight: '600',
},
header: {
alignItems: 'center',
flexDirection: 'row',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
# A custom page's React Native content is mounted as a child of the native host
# and rehosted into the destination its row pushes, then survives the trip back.
appId: com.clerk.exponativebuildfixture
name: UserProfileView renders a custom page
---
- runFlow: subflows/open-app.yaml
- tapOn:
id: 'open-auth-view-button'
- runFlow: subflows/sign-in-email-password.yaml
- runFlow: subflows/assert-signed-in.yaml
- tapOn:
id: 'open-embedded-profile-button'
# Retrying wait: the row list lands a frame after the native profile paints.
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 20000
- tapOn:
text: 'E2E Custom Page'
- extendedWaitUntil:
visible: 'Rehosted RN body'
timeout: 15000
# The Android destination is a bare AndroidView with no back chrome, unlike the
# iOS page which clerk-ios pushes onto its own NavigationStack.
- runFlow:
when:
platform: iOS
commands:
- tapOn: 'Back'
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 15000
# The host chevron firing onHostBack, which reads 'Back' on both platforms.
- tapOn: 'Back'
- extendedWaitUntil:
visible:
id: 'open-embedded-profile-button'
timeout: 15000
- tapOn:
id: 'sign-out-button'
- runFlow: subflows/assert-signed-out.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package expo.modules.clerk

import android.content.Context
import android.content.ContextWrapper
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
Expand DownExpand Up@@ -91,6 +92,21 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte

protected open fun onHostDetachedFromWindow() {}

protected fun layoutAndroidViewHandler(view: View) {
view.post {
val holder = view.parent as? View ?: return@post
val handler = holder.parent as? View ?: return@post
val owner = handler.parent as? View ?: return@post

// AndroidView's handler can enter the hierarchy after its Compose owner was laid out.
handler.measure(
View.MeasureSpec.makeMeasureSpec(owner.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(owner.height, View.MeasureSpec.EXACTLY),
)
handler.layout(0, 0, owner.width, owner.height)
}
}

@Composable
protected abstract fun Content()

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
package expo.modules.clerk

import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.clerk.api.Clerk
import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomRow
import com.clerk.ui.userbutton.UserButton
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher

class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
var customPagesJson: String = "[]"
private val customPageViews = mutableListOf<View>()
private var customNavigator: UserProfileCustomNavigator? = null
private val onCustomPageEvent by EventDispatcher()

init {
activity?.let { Clerk.attachActivity(it) }
}
Expand All@@ -23,15 +37,99 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UserButton(clerkTheme = Clerk.customTheme)
UserButton(
clerkTheme = Clerk.customTheme,
customRows = customRows(),
customDestination =
if (customPageViews.isEmpty()) null
else { routeKey -> CustomPageDestination(routeKey) },
)
}
}

fun addCustomPageView(view: View, index: Int) {
(view.parent as? ViewGroup)?.removeView(view)
customPageViews.add(index.coerceIn(0, customPageViews.size), view)
setupView()
}

fun removeCustomPageView(view: View) {
customPageViews.remove(view)
(view.parent as? ViewGroup)?.removeView(view)
setupView()
}

fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)

fun customPageCount(): Int = customPageViews.size

fun navigateCustomPage(action: String, routeKey: String?) {
when (action) {
"back" -> customNavigator?.navigateBack()
"popToRoot" -> customNavigator?.popToRoot()
"push" -> routeKey?.let { customNavigator?.push(it) }
}
}

@Composable
private fun CustomPageDestination(routeKey: String) {
customNavigator = LocalUserProfileCustomNavigator.current
val rows = customRows()
val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return

LaunchedEffect(routeKey) {
layoutAndroidViewHandler(view)
sendCustomPageEvent("presented", routeKey)
}
DisposableEffect(routeKey) {
onDispose { sendCustomPageEvent("dismissed", routeKey) }
}

AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
(view.parent as? ViewGroup)?.removeView(view)
view
},
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())

private fun sendCustomPageEvent(type: String, path: String) {
onCustomPageEvent(mapOf("type" to type, "path" to path))
}
}

class ClerkUserButtonViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkUserButtonView")

View(ClerkUserButtonNativeView::class) {}
View(ClerkUserButtonNativeView::class) {
Events("onCustomPageEvent")

GroupView<ClerkUserButtonNativeView> {
AddChildView<View> { parent, child, index -> parent.addCustomPageView(child, index) }
GetChildCount { parent -> parent.customPageCount() }
GetChildViewAt<View> { parent, index -> parent.customPageViewAt(index) }
RemoveChildView<View> { parent, child -> parent.removeCustomPageView(child) }
RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
}

AsyncFunction("navigateCustomPage") {
view: ClerkUserButtonNativeView,
action: String,
routeKey: String? -> view.navigateCustomPage(action, routeKey)
}

OnViewDidUpdateProps { view: ClerkUserButtonNativeView ->
view.setupView()
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/expo-native-custom-pages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
---
'@clerk/expo': minor
---

Add custom user profile pages to the native `UserProfileView` and `UserButton` components. Use `content` to render a React Native screen or `href` to open an external URL.

```tsx
import { UserButton, UserProfileView } from '@clerk/expo/native';
import type { UserProfileCustomPage } from '@clerk/expo/native';

const customPages: UserProfileCustomPage[] = [
{
path: 'api-keys',
label: 'API keys',
icon: 'key',
content: <APIKeysView />,
},
{
path: 'docs',
label: 'Docs',
icon: 'book',
href: 'https://clerk.com/docs',
},
];

<UserProfileView customPages={customPages} />;

<UserButton userProfileProps={{ customPages }} />;
```
26 changes: 26 additions & 0 deletions integration/templates/expo-native/App.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,22 @@ function NativeBuildFixture() {
<View style={styles.container}>
<View style={styles.embeddedProfile}>
<UserProfileView
customPages={[
{
path: 'e2e-custom-page',
label: 'E2E Custom Page',
icon: 'key',
placement: { type: 'after', row: 'security' },
content: (
<View
testID='custom-page-content'
style={styles.customPage}
>
<Text style={styles.customPageText}>Rehosted RN body</Text>
</View>
),
},
]}
isDismissible={false}
onHostBack={() => setIsProfileOpen(false)}
/>
Expand DownExpand Up@@ -120,6 +136,16 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
customPage: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 24,
},
customPageText: {
fontSize: 16,
fontWeight: '600',
},
header: {
alignItems: 'center',
flexDirection: 'row',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
# A custom page's React Native content is mounted as a child of the native host
# and rehosted into the destination its row pushes, then survives the trip back.
appId: com.clerk.exponativebuildfixture
name: UserProfileView renders a custom page
---
- runFlow: subflows/open-app.yaml
- tapOn:
id: 'open-auth-view-button'
- runFlow: subflows/sign-in-email-password.yaml
- runFlow: subflows/assert-signed-in.yaml
- tapOn:
id: 'open-embedded-profile-button'
# Retrying wait: the row list lands a frame after the native profile paints.
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 20000
- tapOn:
text: 'E2E Custom Page'
- extendedWaitUntil:
visible: 'Rehosted RN body'
timeout: 15000
# The Android destination is a bare AndroidView with no back chrome, unlike the
# iOS page which clerk-ios pushes onto its own NavigationStack.
- runFlow:
when:
platform: iOS
commands:
- tapOn: 'Back'
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 15000
# The host chevron firing onHostBack, which reads 'Back' on both platforms.
- tapOn: 'Back'
- extendedWaitUntil:
visible:
id: 'open-embedded-profile-button'
timeout: 15000
- tapOn:
id: 'sign-out-button'
- runFlow: subflows/assert-signed-out.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package expo.modules.clerk

import android.content.Context
import android.content.ContextWrapper
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
Expand DownExpand Up@@ -91,6 +92,21 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte

protected open fun onHostDetachedFromWindow() {}

protected fun layoutAndroidViewHandler(view: View) {
view.post {
val holder = view.parent as? View ?: return@post
val handler = holder.parent as? View ?: return@post
val owner = handler.parent as? View ?: return@post

// AndroidView's handler can enter the hierarchy after its Compose owner was laid out.
handler.measure(
View.MeasureSpec.makeMeasureSpec(owner.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(owner.height, View.MeasureSpec.EXACTLY),
)
handler.layout(0, 0, owner.width, owner.height)
}
}

@Composable
protected abstract fun Content()

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
package expo.modules.clerk

import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.clerk.api.Clerk
import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomRow
import com.clerk.ui.userbutton.UserButton
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher

class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
var customPagesJson: String = "[]"
private val customPageViews = mutableListOf<View>()
private var customNavigator: UserProfileCustomNavigator? = null
private val onCustomPageEvent by EventDispatcher()

init {
activity?.let { Clerk.attachActivity(it) }
}
Expand All@@ -23,15 +37,99 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UserButton(clerkTheme = Clerk.customTheme)
UserButton(
clerkTheme = Clerk.customTheme,
customRows = customRows(),
customDestination =
if (customPageViews.isEmpty()) null
else { routeKey -> CustomPageDestination(routeKey) },
)
}
}

fun addCustomPageView(view: View, index: Int) {
(view.parent as? ViewGroup)?.removeView(view)
customPageViews.add(index.coerceIn(0, customPageViews.size), view)
setupView()
}

fun removeCustomPageView(view: View) {
customPageViews.remove(view)
(view.parent as? ViewGroup)?.removeView(view)
setupView()
}

fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)

fun customPageCount(): Int = customPageViews.size

fun navigateCustomPage(action: String, routeKey: String?) {
when (action) {
"back" -> customNavigator?.navigateBack()
"popToRoot" -> customNavigator?.popToRoot()
"push" -> routeKey?.let { customNavigator?.push(it) }
}
}

@Composable
private fun CustomPageDestination(routeKey: String) {
customNavigator = LocalUserProfileCustomNavigator.current
val rows = customRows()
val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return

LaunchedEffect(routeKey) {
layoutAndroidViewHandler(view)
sendCustomPageEvent("presented", routeKey)
}
DisposableEffect(routeKey) {
onDispose { sendCustomPageEvent("dismissed", routeKey) }
}

AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
(view.parent as? ViewGroup)?.removeView(view)
view
},
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())

private fun sendCustomPageEvent(type: String, path: String) {
onCustomPageEvent(mapOf("type" to type, "path" to path))
}
}

class ClerkUserButtonViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkUserButtonView")

View(ClerkUserButtonNativeView::class) {}
View(ClerkUserButtonNativeView::class) {
Events("onCustomPageEvent")

GroupView<ClerkUserButtonNativeView> {
AddChildView<View> { parent, child, index -> parent.addCustomPageView(child, index) }
GetChildCount { parent -> parent.customPageCount() }
GetChildViewAt<View> { parent, index -> parent.customPageViewAt(index) }
RemoveChildView<View> { parent, child -> parent.removeCustomPageView(child) }
RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
}

AsyncFunction("navigateCustomPage") {
view: ClerkUserButtonNativeView,
action: String,
routeKey: String? -> view.navigateCustomPage(action, routeKey)
}

OnViewDidUpdateProps { view: ClerkUserButtonNativeView ->
view.setupView()
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/expo-native-custom-pages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
---
'@clerk/expo': minor
---

Add custom user profile pages to the native `UserProfileView` and `UserButton` components. Use `content` to render a React Native screen or `href` to open an external URL.

```tsx
import { UserButton, UserProfileView } from '@clerk/expo/native';
import type { UserProfileCustomPage } from '@clerk/expo/native';

const customPages: UserProfileCustomPage[] = [
{
path: 'api-keys',
label: 'API keys',
icon: 'key',
content: <APIKeysView />,
},
{
path: 'docs',
label: 'Docs',
icon: 'book',
href: 'https://clerk.com/docs',
},
];

<UserProfileView customPages={customPages} />;

<UserButton userProfileProps={{ customPages }} />;
```
26 changes: 26 additions & 0 deletions integration/templates/expo-native/App.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,22 @@ function NativeBuildFixture() {
<View style={styles.container}>
<View style={styles.embeddedProfile}>
<UserProfileView
customPages={[
{
path: 'e2e-custom-page',
label: 'E2E Custom Page',
icon: 'key',
placement: { type: 'after', row: 'security' },
content: (
<View
testID='custom-page-content'
style={styles.customPage}
>
<Text style={styles.customPageText}>Rehosted RN body</Text>
</View>
),
},
]}
isDismissible={false}
onHostBack={() => setIsProfileOpen(false)}
/>
Expand DownExpand Up@@ -120,6 +136,16 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
customPage: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 24,
},
customPageText: {
fontSize: 16,
fontWeight: '600',
},
header: {
alignItems: 'center',
flexDirection: 'row',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
# A custom page's React Native content is mounted as a child of the native host
# and rehosted into the destination its row pushes, then survives the trip back.
appId: com.clerk.exponativebuildfixture
name: UserProfileView renders a custom page
---
- runFlow: subflows/open-app.yaml
- tapOn:
id: 'open-auth-view-button'
- runFlow: subflows/sign-in-email-password.yaml
- runFlow: subflows/assert-signed-in.yaml
- tapOn:
id: 'open-embedded-profile-button'
# Retrying wait: the row list lands a frame after the native profile paints.
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 20000
- tapOn:
text: 'E2E Custom Page'
- extendedWaitUntil:
visible: 'Rehosted RN body'
timeout: 15000
# The Android destination is a bare AndroidView with no back chrome, unlike the
# iOS page which clerk-ios pushes onto its own NavigationStack.
- runFlow:
when:
platform: iOS
commands:
- tapOn: 'Back'
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 15000
# The host chevron firing onHostBack, which reads 'Back' on both platforms.
- tapOn: 'Back'
- extendedWaitUntil:
visible:
id: 'open-embedded-profile-button'
timeout: 15000
- tapOn:
id: 'sign-out-button'
- runFlow: subflows/assert-signed-out.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package expo.modules.clerk

import android.content.Context
import android.content.ContextWrapper
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
Expand DownExpand Up@@ -91,6 +92,21 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte

protected open fun onHostDetachedFromWindow() {}

protected fun layoutAndroidViewHandler(view: View) {
view.post {
val holder = view.parent as? View ?: return@post
val handler = holder.parent as? View ?: return@post
val owner = handler.parent as? View ?: return@post

// AndroidView's handler can enter the hierarchy after its Compose owner was laid out.
handler.measure(
View.MeasureSpec.makeMeasureSpec(owner.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(owner.height, View.MeasureSpec.EXACTLY),
)
handler.layout(0, 0, owner.width, owner.height)
}
}

@Composable
protected abstract fun Content()

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
package expo.modules.clerk

import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.clerk.api.Clerk
import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomRow
import com.clerk.ui.userbutton.UserButton
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher

class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
var customPagesJson: String = "[]"
private val customPageViews = mutableListOf<View>()
private var customNavigator: UserProfileCustomNavigator? = null
private val onCustomPageEvent by EventDispatcher()

init {
activity?.let { Clerk.attachActivity(it) }
}
Expand All@@ -23,15 +37,99 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UserButton(clerkTheme = Clerk.customTheme)
UserButton(
clerkTheme = Clerk.customTheme,
customRows = customRows(),
customDestination =
if (customPageViews.isEmpty()) null
else { routeKey -> CustomPageDestination(routeKey) },
)
}
}

fun addCustomPageView(view: View, index: Int) {
(view.parent as? ViewGroup)?.removeView(view)
customPageViews.add(index.coerceIn(0, customPageViews.size), view)
setupView()
}

fun removeCustomPageView(view: View) {
customPageViews.remove(view)
(view.parent as? ViewGroup)?.removeView(view)
setupView()
}

fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)

fun customPageCount(): Int = customPageViews.size

fun navigateCustomPage(action: String, routeKey: String?) {
when (action) {
"back" -> customNavigator?.navigateBack()
"popToRoot" -> customNavigator?.popToRoot()
"push" -> routeKey?.let { customNavigator?.push(it) }
}
}

@Composable
private fun CustomPageDestination(routeKey: String) {
customNavigator = LocalUserProfileCustomNavigator.current
val rows = customRows()
val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return

LaunchedEffect(routeKey) {
layoutAndroidViewHandler(view)
sendCustomPageEvent("presented", routeKey)
}
DisposableEffect(routeKey) {
onDispose { sendCustomPageEvent("dismissed", routeKey) }
}

AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
(view.parent as? ViewGroup)?.removeView(view)
view
},
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())

private fun sendCustomPageEvent(type: String, path: String) {
onCustomPageEvent(mapOf("type" to type, "path" to path))
}
}

class ClerkUserButtonViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkUserButtonView")

View(ClerkUserButtonNativeView::class) {}
View(ClerkUserButtonNativeView::class) {
Events("onCustomPageEvent")

GroupView<ClerkUserButtonNativeView> {
AddChildView<View> { parent, child, index -> parent.addCustomPageView(child, index) }
GetChildCount { parent -> parent.customPageCount() }
GetChildViewAt<View> { parent, index -> parent.customPageViewAt(index) }
RemoveChildView<View> { parent, child -> parent.removeCustomPageView(child) }
RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
}

AsyncFunction("navigateCustomPage") {
view: ClerkUserButtonNativeView,
action: String,
routeKey: String? -> view.navigateCustomPage(action, routeKey)
}

OnViewDidUpdateProps { view: ClerkUserButtonNativeView ->
view.setupView()
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/expo-native-custom-pages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
---
'@clerk/expo': minor
---

Add custom user profile pages to the native `UserProfileView` and `UserButton` components. Use `content` to render a React Native screen or `href` to open an external URL.

```tsx
import { UserButton, UserProfileView } from '@clerk/expo/native';
import type { UserProfileCustomPage } from '@clerk/expo/native';

const customPages: UserProfileCustomPage[] = [
{
path: 'api-keys',
label: 'API keys',
icon: 'key',
content: <APIKeysView />,
},
{
path: 'docs',
label: 'Docs',
icon: 'book',
href: 'https://clerk.com/docs',
},
];

<UserProfileView customPages={customPages} />;

<UserButton userProfileProps={{ customPages }} />;
```
26 changes: 26 additions & 0 deletions integration/templates/expo-native/App.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,22 @@ function NativeBuildFixture() {
<View style={styles.container}>
<View style={styles.embeddedProfile}>
<UserProfileView
customPages={[
{
path: 'e2e-custom-page',
label: 'E2E Custom Page',
icon: 'key',
placement: { type: 'after', row: 'security' },
content: (
<View
testID='custom-page-content'
style={styles.customPage}
>
<Text style={styles.customPageText}>Rehosted RN body</Text>
</View>
),
},
]}
isDismissible={false}
onHostBack={() => setIsProfileOpen(false)}
/>
Expand DownExpand Up@@ -120,6 +136,16 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
customPage: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 24,
},
customPageText: {
fontSize: 16,
fontWeight: '600',
},
header: {
alignItems: 'center',
flexDirection: 'row',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
# A custom page's React Native content is mounted as a child of the native host
# and rehosted into the destination its row pushes, then survives the trip back.
appId: com.clerk.exponativebuildfixture
name: UserProfileView renders a custom page
---
- runFlow: subflows/open-app.yaml
- tapOn:
id: 'open-auth-view-button'
- runFlow: subflows/sign-in-email-password.yaml
- runFlow: subflows/assert-signed-in.yaml
- tapOn:
id: 'open-embedded-profile-button'
# Retrying wait: the row list lands a frame after the native profile paints.
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 20000
- tapOn:
text: 'E2E Custom Page'
- extendedWaitUntil:
visible: 'Rehosted RN body'
timeout: 15000
# The Android destination is a bare AndroidView with no back chrome, unlike the
# iOS page which clerk-ios pushes onto its own NavigationStack.
- runFlow:
when:
platform: iOS
commands:
- tapOn: 'Back'
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 15000
# The host chevron firing onHostBack, which reads 'Back' on both platforms.
- tapOn: 'Back'
- extendedWaitUntil:
visible:
id: 'open-embedded-profile-button'
timeout: 15000
- tapOn:
id: 'sign-out-button'
- runFlow: subflows/assert-signed-out.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package expo.modules.clerk

import android.content.Context
import android.content.ContextWrapper
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
Expand DownExpand Up@@ -91,6 +92,21 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte

protected open fun onHostDetachedFromWindow() {}

protected fun layoutAndroidViewHandler(view: View) {
view.post {
val holder = view.parent as? View ?: return@post
val handler = holder.parent as? View ?: return@post
val owner = handler.parent as? View ?: return@post

// AndroidView's handler can enter the hierarchy after its Compose owner was laid out.
handler.measure(
View.MeasureSpec.makeMeasureSpec(owner.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(owner.height, View.MeasureSpec.EXACTLY),
)
handler.layout(0, 0, owner.width, owner.height)
}
}

@Composable
protected abstract fun Content()

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
package expo.modules.clerk

import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.clerk.api.Clerk
import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomRow
import com.clerk.ui.userbutton.UserButton
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher

class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
var customPagesJson: String = "[]"
private val customPageViews = mutableListOf<View>()
private var customNavigator: UserProfileCustomNavigator? = null
private val onCustomPageEvent by EventDispatcher()

init {
activity?.let { Clerk.attachActivity(it) }
}
Expand All@@ -23,15 +37,99 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UserButton(clerkTheme = Clerk.customTheme)
UserButton(
clerkTheme = Clerk.customTheme,
customRows = customRows(),
customDestination =
if (customPageViews.isEmpty()) null
else { routeKey -> CustomPageDestination(routeKey) },
)
}
}

fun addCustomPageView(view: View, index: Int) {
(view.parent as? ViewGroup)?.removeView(view)
customPageViews.add(index.coerceIn(0, customPageViews.size), view)
setupView()
}

fun removeCustomPageView(view: View) {
customPageViews.remove(view)
(view.parent as? ViewGroup)?.removeView(view)
setupView()
}

fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)

fun customPageCount(): Int = customPageViews.size

fun navigateCustomPage(action: String, routeKey: String?) {
when (action) {
"back" -> customNavigator?.navigateBack()
"popToRoot" -> customNavigator?.popToRoot()
"push" -> routeKey?.let { customNavigator?.push(it) }
}
}

@Composable
private fun CustomPageDestination(routeKey: String) {
customNavigator = LocalUserProfileCustomNavigator.current
val rows = customRows()
val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return

LaunchedEffect(routeKey) {
layoutAndroidViewHandler(view)
sendCustomPageEvent("presented", routeKey)
}
DisposableEffect(routeKey) {
onDispose { sendCustomPageEvent("dismissed", routeKey) }
}

AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
(view.parent as? ViewGroup)?.removeView(view)
view
},
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())

private fun sendCustomPageEvent(type: String, path: String) {
onCustomPageEvent(mapOf("type" to type, "path" to path))
}
}

class ClerkUserButtonViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkUserButtonView")

View(ClerkUserButtonNativeView::class) {}
View(ClerkUserButtonNativeView::class) {
Events("onCustomPageEvent")

GroupView<ClerkUserButtonNativeView> {
AddChildView<View> { parent, child, index -> parent.addCustomPageView(child, index) }
GetChildCount { parent -> parent.customPageCount() }
GetChildViewAt<View> { parent, index -> parent.customPageViewAt(index) }
RemoveChildView<View> { parent, child -> parent.removeCustomPageView(child) }
RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
}

AsyncFunction("navigateCustomPage") {
view: ClerkUserButtonNativeView,
action: String,
routeKey: String? -> view.navigateCustomPage(action, routeKey)
}

OnViewDidUpdateProps { view: ClerkUserButtonNativeView ->
view.setupView()
}
}
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/expo-native-custom-pages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
---
'@clerk/expo': minor
---

Add custom user profile pages to the native `UserProfileView` and `UserButton` components. Use `content` to render a React Native screen or `href` to open an external URL.

```tsx
import { UserButton, UserProfileView } from '@clerk/expo/native';
import type { UserProfileCustomPage } from '@clerk/expo/native';

const customPages: UserProfileCustomPage[] = [
{
path: 'api-keys',
label: 'API keys',
icon: 'key',
content: <APIKeysView />,
},
{
path: 'docs',
label: 'Docs',
icon: 'book',
href: 'https://clerk.com/docs',
},
];

<UserProfileView customPages={customPages} />;

<UserButton userProfileProps={{ customPages }} />;
```
26 changes: 26 additions & 0 deletions integration/templates/expo-native/App.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,22 @@ function NativeBuildFixture() {
<View style={styles.container}>
<View style={styles.embeddedProfile}>
<UserProfileView
customPages={[
{
path: 'e2e-custom-page',
label: 'E2E Custom Page',
icon: 'key',
placement: { type: 'after', row: 'security' },
content: (
<View
testID='custom-page-content'
style={styles.customPage}
>
<Text style={styles.customPageText}>Rehosted RN body</Text>
</View>
),
},
]}
isDismissible={false}
onHostBack={() => setIsProfileOpen(false)}
/>
Expand DownExpand Up@@ -120,6 +136,16 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
customPage: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 24,
},
customPageText: {
fontSize: 16,
fontWeight: '600',
},
header: {
alignItems: 'center',
flexDirection: 'row',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
# A custom page's React Native content is mounted as a child of the native host
# and rehosted into the destination its row pushes, then survives the trip back.
appId: com.clerk.exponativebuildfixture
name: UserProfileView renders a custom page
---
- runFlow: subflows/open-app.yaml
- tapOn:
id: 'open-auth-view-button'
- runFlow: subflows/sign-in-email-password.yaml
- runFlow: subflows/assert-signed-in.yaml
- tapOn:
id: 'open-embedded-profile-button'
# Retrying wait: the row list lands a frame after the native profile paints.
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 20000
- tapOn:
text: 'E2E Custom Page'
- extendedWaitUntil:
visible: 'Rehosted RN body'
timeout: 15000
# The Android destination is a bare AndroidView with no back chrome, unlike the
# iOS page which clerk-ios pushes onto its own NavigationStack.
- runFlow:
when:
platform: iOS
commands:
- tapOn: 'Back'
- runFlow:
when:
platform: Android
commands:
- back
- extendedWaitUntil:
visible: 'E2E Custom Page'
timeout: 15000
# The host chevron firing onHostBack, which reads 'Back' on both platforms.
- tapOn: 'Back'
- extendedWaitUntil:
visible:
id: 'open-embedded-profile-button'
timeout: 15000
- tapOn:
id: 'sign-out-button'
- runFlow: subflows/assert-signed-out.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package expo.modules.clerk

import android.content.Context
import android.content.ContextWrapper
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
Expand DownExpand Up@@ -91,6 +92,21 @@ abstract class ClerkComposeNativeViewHost(context: Context, appContext: AppConte

protected open fun onHostDetachedFromWindow() {}

protected fun layoutAndroidViewHandler(view: View) {
view.post {
val holder = view.parent as? View ?: return@post
val handler = holder.parent as? View ?: return@post
val owner = handler.parent as? View ?: return@post

// AndroidView's handler can enter the hierarchy after its Compose owner was laid out.
handler.measure(
View.MeasureSpec.makeMeasureSpec(owner.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(owner.height, View.MeasureSpec.EXACTLY),
)
handler.layout(0, 0, owner.width, owner.height)
}
}

@Composable
protected abstract fun Content()

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
package expo.modules.clerk

import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.clerk.api.Clerk
import com.clerk.ui.userprofile.custom.LocalUserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomNavigator
import com.clerk.ui.userprofile.custom.UserProfileCustomRow
import com.clerk.ui.userbutton.UserButton
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher

class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
var customPagesJson: String = "[]"
private val customPageViews = mutableListOf<View>()
private var customNavigator: UserProfileCustomNavigator? = null
private val onCustomPageEvent by EventDispatcher()

init {
activity?.let { Clerk.attachActivity(it) }
}
Expand All@@ -23,15 +37,99 @@ class ClerkUserButtonNativeView(context: Context, appContext: AppContext) : Cler
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
UserButton(clerkTheme = Clerk.customTheme)
UserButton(
clerkTheme = Clerk.customTheme,
customRows = customRows(),
customDestination =
if (customPageViews.isEmpty()) null
else { routeKey -> CustomPageDestination(routeKey) },
)
}
}

fun addCustomPageView(view: View, index: Int) {
(view.parent as? ViewGroup)?.removeView(view)
customPageViews.add(index.coerceIn(0, customPageViews.size), view)
setupView()
}

fun removeCustomPageView(view: View) {
customPageViews.remove(view)
(view.parent as? ViewGroup)?.removeView(view)
setupView()
}

fun customPageViewAt(index: Int): View? = customPageViews.getOrNull(index)

fun customPageCount(): Int = customPageViews.size

fun navigateCustomPage(action: String, routeKey: String?) {
when (action) {
"back" -> customNavigator?.navigateBack()
"popToRoot" -> customNavigator?.popToRoot()
"push" -> routeKey?.let { customNavigator?.push(it) }
}
}

@Composable
private fun CustomPageDestination(routeKey: String) {
customNavigator = LocalUserProfileCustomNavigator.current
val rows = customRows()
val view = customPageViews.getOrNull(rows.indexOfFirst { it.routeKey == routeKey }) ?: return

LaunchedEffect(routeKey) {
layoutAndroidViewHandler(view)
sendCustomPageEvent("presented", routeKey)
}
DisposableEffect(routeKey) {
onDispose { sendCustomPageEvent("dismissed", routeKey) }
}

AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
(view.parent as? ViewGroup)?.removeView(view)
view
},
)
}

private fun customRows(): List<UserProfileCustomRow> =
runCatching { parseUserProfileCustomPages(customPagesJson, customPageViews.size) }.getOrDefault(emptyList())

private fun sendCustomPageEvent(type: String, path: String) {
onCustomPageEvent(mapOf("type" to type, "path" to path))
}
}

class ClerkUserButtonViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkUserButtonView")

View(ClerkUserButtonNativeView::class) {}
View(ClerkUserButtonNativeView::class) {
Events("onCustomPageEvent")

GroupView<ClerkUserButtonNativeView> {
AddChildView<View> { parent, child, index -> parent.addCustomPageView(child, index) }
GetChildCount { parent -> parent.customPageCount() }
GetChildViewAt<View> { parent, index -> parent.customPageViewAt(index) }
RemoveChildView<View> { parent, child -> parent.removeCustomPageView(child) }
RemoveChildViewAt { parent, index -> parent.customPageViewAt(index)?.let(parent::removeCustomPageView) }
}

Prop("customPages") { view: ClerkUserButtonNativeView, customPages: String ->
view.customPagesJson = customPages
}

AsyncFunction("navigateCustomPage") {
view: ClerkUserButtonNativeView,
action: String,
routeKey: String? -> view.navigateCustomPage(action, routeKey)
}

OnViewDidUpdateProps { view: ClerkUserButtonNativeView ->
view.setupView()
}
}
}
}
Loading
Loading