@kontextso/sdk-react-native 3.0.7-rc.2 → 4.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Preload.ts ADDED
@@ -0,0 +1,150 @@
1
+ import type { Message } from '@kontextso/sdk-common'
2
+ import type{ Session } from './Session'
3
+ import { fetchWithRetry } from './utils/request'
4
+
5
+ type Bid = {
6
+ bidId: string
7
+ code: string
8
+ }
9
+
10
+ interface PreloadResponse {
11
+ bids?: Bid[]
12
+ sessionId?: string
13
+ skipCode?: string
14
+ skip?: boolean
15
+
16
+ // Error state
17
+ error?: string
18
+ errCode?: string
19
+ permanent?: boolean
20
+ }
21
+
22
+ export class Preload {
23
+ public readonly session: Session
24
+
25
+ private messages: Message[] = []
26
+ private abortController: AbortController
27
+ private running = false
28
+ private bid: Bid | null = null
29
+
30
+ constructor(session: Session, messages: Message[]) {
31
+ this.session = session
32
+ this.abortController = new AbortController()
33
+ this.setMessages(messages)
34
+ }
35
+
36
+ private setMessages (messages: Message[]) {
37
+ this.messages = messages
38
+ }
39
+
40
+ hasBid () {
41
+ return this.bid !== null
42
+ }
43
+
44
+ getBid () {
45
+ return this.bid
46
+ }
47
+
48
+ private setBid (bid: Bid) {
49
+ this.bid = bid
50
+ }
51
+
52
+ cancel () {
53
+ this.abortController.abort()
54
+ this.running = false
55
+ }
56
+
57
+ isRunning () {
58
+ return this.running
59
+ }
60
+
61
+ /*
62
+ private async getDevice () {
63
+ try {
64
+ return await getDevice()
65
+ } catch (error) {
66
+ this.session.logger.error('Error getting device:', error)
67
+ return {} as DeviceConfig
68
+ }
69
+ }
70
+ */
71
+
72
+ private async getPreloadBody () {
73
+ const config = this.session.config
74
+ const preloadBody: Record<string, any> = {
75
+ messages: this.messages,
76
+ sessionId: this.session.getSessionId(),
77
+ publisherToken: config.publisherToken,
78
+ userId: config.userId,
79
+ userEmail: config.userEmail,
80
+ conversationId: config.conversationId,
81
+ character: config.character,
82
+ enabledPlacementCodes: [config.placementCode],
83
+ variantId: config.variantId,
84
+ regulatory: config.regulatory,
85
+ sdk: this.session.sdk,
86
+ }
87
+ return preloadBody
88
+ }
89
+
90
+ private canRequestAd () {
91
+ if (this.session.isSessionDisabled()) {
92
+ return {
93
+ status: 'error',
94
+ message: 'Session is disabled',
95
+ }
96
+ }
97
+ if (!this.messages.length) {
98
+ return {
99
+ status: 'error',
100
+ message: 'No messages',
101
+ }
102
+ }
103
+ return null
104
+ }
105
+
106
+ async requestAd () {
107
+ this.running = true
108
+ const cannotRequestAdReason = this.canRequestAd()
109
+ if (cannotRequestAdReason) {
110
+ return cannotRequestAdReason
111
+ }
112
+
113
+ const config = this.session.config
114
+ const preloadBody = await this.getPreloadBody()
115
+
116
+ try {
117
+ const response = await fetchWithRetry(`${config.adServerUrl}/preload`, {
118
+ method: 'POST',
119
+ body: JSON.stringify(preloadBody),
120
+ headers: {
121
+ 'Kontextso-Is-Disabled': '0',
122
+ 'Kontextso-Publisher-Token': config.publisherToken,
123
+ },
124
+ timeout: 16000,
125
+ abortController: this.abortController,
126
+ })
127
+
128
+ const jsonResponse = await response.json() as PreloadResponse
129
+
130
+ const bid = jsonResponse.bids?.[0] ?? null
131
+ if (jsonResponse.sessionId) {
132
+ this.session.setSessionId(jsonResponse.sessionId)
133
+ }
134
+ if (bid) {
135
+ this.setBid(bid)
136
+ }
137
+ this.session.updateBids()
138
+ return {
139
+ status: 'success',
140
+ }
141
+ } catch (error) {
142
+ this.session.logger.error('Error requesting ad:', error)
143
+ return {
144
+ status: 'error',
145
+ }
146
+ } finally {
147
+ this.running = false
148
+ }
149
+ }
150
+ }
package/src/Session.ts ADDED
@@ -0,0 +1,104 @@
1
+ import type { Message } from '@kontextso/sdk-common'
2
+ import type { Configuration } from './Configuration'
3
+ import { Logger } from './Logger'
4
+ import { Preload } from './Preload'
5
+ import type { SDKConfig } from './utils/sdk'
6
+
7
+ export class Session {
8
+ public readonly config: Configuration
9
+ public readonly logger: Logger
10
+ public readonly sdk: SDKConfig
11
+
12
+ private sessionId: string | null = null
13
+ private sessionDisabled = false
14
+ private messages: Message[] = []
15
+ private bids: {
16
+ bidId: string
17
+ messageId: string
18
+ }[] = []
19
+ private onUpdateBidsCallback: (() => void) | null = null
20
+
21
+
22
+ // only one preload instance is allowed at a time
23
+ private preloadInstance: Preload | null = null
24
+
25
+ constructor(config: Configuration, { sdk }: { sdk: SDKConfig }) {
26
+ this.config = config
27
+ this.logger = new Logger()
28
+ this.sdk = sdk
29
+ }
30
+
31
+ setOnUpdateBids (callback: () => void) {
32
+ this.onUpdateBidsCallback = callback
33
+ }
34
+
35
+ isSessionDisabled () {
36
+ return this.sessionDisabled
37
+ }
38
+
39
+ setSessionDisabled (disabled: boolean) {
40
+ this.sessionDisabled = disabled
41
+ }
42
+
43
+ getSessionId () {
44
+ return this.sessionId
45
+ }
46
+
47
+ setSessionId (sessionId: string) {
48
+ this.logger.info('Session ID set:', sessionId)
49
+ this.sessionId = sessionId
50
+ }
51
+
52
+ addMessage (message: Message) {
53
+ this.messages.push(message)
54
+ this.updateBids()
55
+ }
56
+
57
+ getMessages () {
58
+ return this.messages
59
+ }
60
+
61
+ public updateBids() {
62
+ if (!this.preloadInstance?.hasBid()) {
63
+ return
64
+ }
65
+ const bid = this.preloadInstance.getBid()
66
+ if (!bid) {
67
+
68
+ return
69
+ }
70
+ const lastMessage = this.messages[this.messages.length - 1]
71
+ if (!lastMessage || lastMessage.role !== 'assistant') {
72
+ return
73
+ }
74
+ const assignedBid = this.bids.find((b) => {
75
+ if (b.bidId === bid?.bidId) {
76
+ return true
77
+ }
78
+ if (b.messageId === lastMessage.id) {
79
+ return true
80
+ }
81
+ return false
82
+ })
83
+ if (assignedBid) {
84
+ return
85
+ }
86
+ this.bids.push({
87
+ bidId: bid.bidId,
88
+ messageId: lastMessage.id,
89
+ })
90
+ this.onUpdateBidsCallback?.()
91
+ }
92
+
93
+ preload () {
94
+ if (this.preloadInstance?.isRunning()) {
95
+ this.preloadInstance.cancel()
96
+ }
97
+ this.preloadInstance = new Preload(this, [...this.messages])
98
+ return this.preloadInstance
99
+ }
100
+
101
+ getLastBid () {
102
+ return this.bids[this.bids.length - 1]
103
+ }
104
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,3 @@
1
- import InlineAd from './formats/InlineAd'
1
+ export { InlineAd } from './InlineAd'
2
+ export { KontextAds } from './KontextAds'
2
3
 
3
- export * from './context/AdsProvider'
4
-
5
- export { InlineAd }
@@ -0,0 +1,89 @@
1
+
2
+ import { detectDeviceType, detectOS, parseUserAgent } from '@kontextso/utils/user-agent'
3
+
4
+ interface DeviceHardware {
5
+ type: 'handset' | 'tablet' | 'desktop' | 'tv' | 'other'
6
+ bootTime?: number
7
+ brand?: string
8
+ model?: string
9
+ sdCardAvailable?: boolean
10
+ }
11
+
12
+ interface OperatingSystem {
13
+ name: string
14
+ version: string
15
+ locale: string
16
+ timezone: string
17
+ }
18
+
19
+ interface ScreenInfo {
20
+ darkMode: boolean
21
+ dpr: number
22
+ height: number
23
+ width: number
24
+ orientation?: 'portrait' | 'landscape'
25
+ }
26
+
27
+ interface AudioInfo {
28
+ volume?: number
29
+ muted?: boolean
30
+ outputPluggedIn?: boolean
31
+ outputType?: Array<'wired' | 'hdmi' | 'bluetooth' | 'usb' | 'other'>
32
+ }
33
+
34
+ interface NetworkInfo {
35
+ carrier?: string
36
+ detail?: string
37
+ type?: 'wifi' | 'cellular' | 'ethernet' | 'other'
38
+ userAgent?: string
39
+ }
40
+
41
+ interface PowerInfo {
42
+ batteryLevel?: number
43
+ batteryState?: 'charging' | 'full' | 'unplugged' | 'unknown'
44
+ lowPowerMode?: boolean
45
+ }
46
+
47
+ export interface DeviceConfig {
48
+ hardware: DeviceHardware
49
+ os: OperatingSystem
50
+ screen: ScreenInfo
51
+ audio?: AudioInfo
52
+ network?: NetworkInfo
53
+ power?: PowerInfo
54
+ }
55
+
56
+ export const getDevice = async (): Promise<DeviceConfig> => {
57
+ const userAgent = navigator.userAgent
58
+ const parsedUserAgent = parseUserAgent(userAgent)
59
+ const parsedDevice = parsedUserAgent?.getDevice()
60
+ const parsedOS = parsedUserAgent?.getOS()
61
+ // @ts-expect-error Not in types
62
+ const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection
63
+
64
+ return {
65
+ network: {
66
+ userAgent,
67
+ detail: connection?.effectiveType,
68
+ type: connection?.type,
69
+ },
70
+ screen: {
71
+ width: screen.width,
72
+ height: screen.height,
73
+ dpr: window.devicePixelRatio || 1,
74
+ orientation: screen.orientation?.type?.includes('portrait') ? 'portrait' : 'landscape',
75
+ darkMode: window.matchMedia?.('(prefers-color-scheme: dark)').matches,
76
+ },
77
+ hardware: {
78
+ type: detectDeviceType(parsedUserAgent),
79
+ brand: parsedDevice?.vendor,
80
+ model: parsedDevice?.model,
81
+ },
82
+ os: {
83
+ name: detectOS(parsedUserAgent),
84
+ version: parsedOS?.version || '',
85
+ locale: navigator.language || 'en-US',
86
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
87
+ },
88
+ }
89
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Request function with retry and exponential backoff
3
+ */
4
+ export async function fetchWithRetry(
5
+ url: string,
6
+ options: RequestInit & {
7
+ retry?: {
8
+ maxRetries?: number
9
+ baseDelay?: number
10
+ backoffFactor?: number
11
+ }
12
+ timeout?: number
13
+ abortController?: AbortController
14
+ } = {}
15
+ ): Promise<Response> {
16
+ const { retry = {}, timeout, abortController: abortControllerOption, ...fetchOptions } = options
17
+
18
+ const maxRetries = retry.maxRetries ?? 3
19
+ const baseDelay = retry.baseDelay ?? 1000
20
+ const backoffFactor = retry.backoffFactor ?? 2
21
+
22
+ const abortController = abortControllerOption || new AbortController()
23
+ fetchOptions.signal = abortController.signal
24
+
25
+ let timeoutId: ReturnType<typeof setTimeout> | null = null
26
+ if (timeout) {
27
+ timeoutId = setTimeout(() => abortController.abort('timeout'), timeout)
28
+ }
29
+
30
+ let lastError: Error | null = null
31
+
32
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
33
+ try {
34
+ const response = await fetch(url, fetchOptions)
35
+
36
+ // Throw only re-tryable errors, otherwise return the response to let the caller handle it
37
+ if (response.status >= 500 || response.status === 429) {
38
+ const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
39
+ ;(error as any).status = response.status
40
+ throw error
41
+ }
42
+
43
+ return response
44
+ } catch (error) {
45
+ if (abortController.signal.aborted) {
46
+ throw new Error(`Fetch aborted: ${abortController.signal.reason}`)
47
+ }
48
+
49
+ lastError = error instanceof Error ? error : new Error('Unknown error')
50
+
51
+ // Retry on network errors and 5xx status codes
52
+ const shouldRetry =
53
+ error instanceof TypeError || // Network error
54
+ (error as any).status >= 500 || // Server error
55
+ (error as any).status === 429 // Rate limited
56
+
57
+ // Don't retry on last attempt or if error shouldn't be retried
58
+ if (attempt === maxRetries || !shouldRetry) {
59
+ break
60
+ }
61
+
62
+ // Wait before retry (exponential backoff)
63
+ const delay = baseDelay * backoffFactor ** attempt
64
+ await new Promise((resolve) => setTimeout(resolve, delay))
65
+ } finally {
66
+ if (timeoutId) {
67
+ clearTimeout(timeoutId)
68
+ }
69
+ }
70
+ }
71
+
72
+ throw lastError
73
+ }
@@ -0,0 +1,16 @@
1
+
2
+ import { Platform } from '@kontextso/utils/user-agent'
3
+ import type { SDK } from '@kontextso/sdk-common'
4
+ import { version } from '../../package.json'
5
+
6
+ export interface SDKConfig {
7
+ name: Exclude<SDK, 'sdk'>
8
+ platform: 'ios' | 'android' | 'web'
9
+ version: string
10
+ }
11
+
12
+ export const getSdk = async (): Promise<SDKConfig> => ({
13
+ name: 'sdk-js',
14
+ platform: Platform.Web,
15
+ version,
16
+ })
@@ -0,0 +1,59 @@
1
+ import type { FetchAdParams } from "./interface"
2
+
3
+ const DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
4
+
5
+ export const validateFetchAdSettings = (settings: FetchAdParams) => {
6
+ if (!settings.adServerUrl) {
7
+ throw new Error('Server URL is required.')
8
+ }
9
+ if (!settings.publisherToken) {
10
+ throw new Error('Publisher token is required.')
11
+ }
12
+ if (!settings.conversationId) {
13
+ throw new Error('Conversation ID is required.')
14
+ }
15
+ if (typeof settings.conversationId !== 'string') {
16
+ throw new Error('Conversation ID must be a string.')
17
+ }
18
+ if (!settings.userId) {
19
+ throw new Error('User ID is required.')
20
+ }
21
+ if (typeof settings.userId !== 'string') {
22
+ throw new Error('User ID must be a string.')
23
+ }
24
+ if (!settings.enabledPlacementCodes || !Array.isArray(settings.enabledPlacementCodes) || settings.enabledPlacementCodes.length === 0) {
25
+ throw new Error('Placement code is required.')
26
+ }
27
+ if (!settings.messages || !Array.isArray(settings.messages) || settings.messages.length === 0) {
28
+ throw new Error('Messages are required.')
29
+ }
30
+ settings.messages.forEach((message: any) => {
31
+ if (!message.id) {
32
+ throw new Error('Message ID is required.')
33
+ }
34
+ if (typeof message.id !== 'string') {
35
+ throw new Error('Message ID must be a string.')
36
+ }
37
+ if (!message.content) {
38
+ throw new Error('Message content is required.')
39
+ }
40
+ if (!message.createdAt) {
41
+ throw new Error('Message createdAt is required.')
42
+ }
43
+ if (typeof message.createdAt !== 'string' && !(message.createdAt instanceof Date)) {
44
+ throw new Error('Message createdAt must be string or Date object.')
45
+ }
46
+ if (typeof message.createdAt === 'string' && !DATE_REGEX.test(message.createdAt)) {
47
+ throw new Error('Message createdAt must be a valid ISO 8601 date string.')
48
+ }
49
+ if (message.createdAt instanceof Date && Number.isNaN(message.createdAt.getTime())) {
50
+ throw new Error('Message createdAt must be a valid Date object.')
51
+ }
52
+ if (!message.role) {
53
+ throw new Error('Message role is required.')
54
+ }
55
+ if (message.role !== 'assistant' && message.role !== 'user') {
56
+ throw new Error('Message role must be either "assistant" or "user".')
57
+ }
58
+ })
59
+ }
@@ -1,88 +0,0 @@
1
- buildscript {
2
- ext.getExtOrDefault = {name ->
3
- return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNKontext_' + name]
4
- }
5
-
6
- repositories {
7
- google()
8
- mavenCentral()
9
- }
10
-
11
- dependencies {
12
- classpath "com.android.tools.build:gradle:8.7.2"
13
- // noinspection DifferentKotlinGradleVersion
14
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
15
- }
16
- }
17
-
18
-
19
- apply plugin: "com.android.library"
20
- apply plugin: "kotlin-android"
21
-
22
- apply plugin: "com.facebook.react"
23
-
24
- def isNewArchitectureEnabled() {
25
- return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
26
- }
27
-
28
- def getExtOrIntegerDefault(name) {
29
- return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["RNKontext_" + name]).toInteger()
30
- }
31
-
32
- android {
33
- namespace "so.kontext.react"
34
-
35
- compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
36
-
37
- defaultConfig {
38
- minSdkVersion getExtOrIntegerDefault("minSdkVersion")
39
- targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
40
- // Expose the new-arch flag to runtime and build-time Kotlin/Java code
41
- buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
42
- }
43
-
44
- buildFeatures {
45
- buildConfig true
46
- }
47
-
48
- buildTypes {
49
- release {
50
- minifyEnabled false
51
- }
52
- }
53
-
54
- lintOptions {
55
- disable "GradleCompatible"
56
- }
57
-
58
- compileOptions {
59
- sourceCompatibility JavaVersion.VERSION_1_8
60
- targetCompatibility JavaVersion.VERSION_1_8
61
- }
62
-
63
- sourceSets {
64
- main {
65
- java.srcDirs += [
66
- "generated/java",
67
- "generated/jni"
68
- ]
69
- if (isNewArchitectureEnabled()) {
70
- java.srcDirs += ['src/newarch/java']
71
- } else {
72
- java.srcDirs += ['src/oldarch/java']
73
- }
74
- }
75
- }
76
- }
77
-
78
- repositories {
79
- mavenCentral()
80
- google()
81
- }
82
-
83
- def kotlin_version = getExtOrDefault("kotlinVersion")
84
-
85
- dependencies {
86
- implementation "com.facebook.react:react-android"
87
- implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
88
- }
@@ -1,5 +0,0 @@
1
- RNKontext_kotlinVersion=2.0.21
2
- RNKontext_minSdkVersion=24
3
- RNKontext_targetSdkVersion=34
4
- RNKontext_compileSdkVersion=35
5
- RNKontext_ndkVersion=27.1.12297006
@@ -1,2 +0,0 @@
1
- <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
- </manifest>
@@ -1,21 +0,0 @@
1
- package so.kontext.react
2
-
3
- import com.facebook.react.bridge.Promise
4
- import com.facebook.react.bridge.ReactApplicationContext
5
- import android.content.Context
6
- import android.media.AudioManager
7
-
8
- class RNKontextModuleImpl(private val reactContext: ReactApplicationContext) {
9
- fun isSoundOn(promise: Promise?) {
10
- val audioManager = reactContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
11
- val current = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
12
- val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
13
- val volume = if (max > 0) current.toFloat() / max.toFloat() else 0f
14
-
15
- promise?.resolve(volume > MINIMAL_VOLUME_THRESHOLD)
16
- }
17
-
18
- companion object {
19
- private const val MINIMAL_VOLUME_THRESHOLD = 0.0f
20
- }
21
- }
@@ -1,22 +0,0 @@
1
- package so.kontext.react
2
-
3
- import com.facebook.react.bridge.ReactApplicationContext
4
- import com.facebook.react.module.annotations.ReactModule
5
- import com.facebook.react.bridge.Promise
6
-
7
- @ReactModule(name = RNKontextModule.NAME)
8
- class RNKontextModule(reactContext: ReactApplicationContext) :
9
- NativeRNKontextSpec(reactContext) {
10
-
11
- private val impl = RNKontextModuleImpl(reactContext)
12
-
13
- override fun getName(): String = NAME
14
-
15
- override fun isSoundOn(promise: Promise?) {
16
- impl.isSoundOn(promise)
17
- }
18
-
19
- companion object {
20
- const val NAME = "RNKontext"
21
- }
22
- }
@@ -1,32 +0,0 @@
1
- package so.kontext.react
2
-
3
- import com.facebook.react.BaseReactPackage
4
- import com.facebook.react.bridge.NativeModule
5
- import com.facebook.react.bridge.ReactApplicationContext
6
- import com.facebook.react.module.model.ReactModuleInfo
7
- import com.facebook.react.module.model.ReactModuleInfoProvider
8
-
9
- class RNKontextPackage : BaseReactPackage() {
10
- override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
11
- return if (name == RNKontextModule.NAME) {
12
- RNKontextModule(reactContext)
13
- } else {
14
- null
15
- }
16
- }
17
-
18
- override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
19
- return ReactModuleInfoProvider {
20
- mapOf(
21
- RNKontextModule.NAME to ReactModuleInfo(
22
- RNKontextModule.NAME,
23
- RNKontextModule.NAME,
24
- false, // canOverrideExistingModule
25
- false, // needsEagerInit
26
- false, // isCxxModule
27
- true // isTurboModule
28
- )
29
- )
30
- }
31
- }
32
- }