@planningcenter/chat-react-native 3.20.1-rc.2 → 3.20.1-rc.3
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/build/contexts/chat_context.d.ts +2 -0
- package/build/contexts/chat_context.d.ts.map +1 -1
- package/build/contexts/chat_context.js +40 -1
- package/build/contexts/chat_context.js.map +1 -1
- package/build/hooks/index.d.ts +1 -0
- package/build/hooks/index.d.ts.map +1 -1
- package/build/hooks/index.js +1 -0
- package/build/hooks/index.js.map +1 -1
- package/build/hooks/use_conversation.d.ts +1 -0
- package/build/hooks/use_conversation.d.ts.map +1 -1
- package/build/hooks/use_conversation.js +11 -2
- package/build/hooks/use_conversation.js.map +1 -1
- package/build/hooks/use_product_analytics.d.ts +9 -0
- package/build/hooks/use_product_analytics.d.ts.map +1 -0
- package/build/hooks/use_product_analytics.js +68 -0
- package/build/hooks/use_product_analytics.js.map +1 -0
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +6 -0
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
- package/build/screens/conversations/conversations_screen.js +2 -1
- package/build/screens/conversations/conversations_screen.js.map +1 -1
- package/build/types/product_analytics.d.ts +6 -0
- package/build/types/product_analytics.d.ts.map +1 -0
- package/build/types/product_analytics.js +2 -0
- package/build/types/product_analytics.js.map +1 -0
- package/build/types/resources/analytics_metadata.d.ts +5 -0
- package/build/types/resources/analytics_metadata.d.ts.map +1 -0
- package/build/types/resources/analytics_metadata.js +2 -0
- package/build/types/resources/analytics_metadata.js.map +1 -0
- package/build/types/resources/conversation.d.ts +2 -0
- package/build/types/resources/conversation.d.ts.map +1 -1
- package/build/types/resources/conversation.js.map +1 -1
- package/build/types/resources/index.d.ts +1 -0
- package/build/types/resources/index.d.ts.map +1 -1
- package/build/types/resources/index.js +1 -0
- package/build/types/resources/index.js.map +1 -1
- package/build/utils/request/conversation.d.ts.map +1 -1
- package/build/utils/request/conversation.js +2 -1
- package/build/utils/request/conversation.js.map +1 -1
- package/build/utils/sha_256.d.ts +31 -0
- package/build/utils/sha_256.d.ts.map +1 -0
- package/build/utils/sha_256.js +195 -0
- package/build/utils/sha_256.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/hooks/useTheme.tsx +40 -16
- package/src/contexts/chat_context.tsx +59 -2
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use_conversation.ts +12 -2
- package/src/hooks/use_product_analytics.ts +107 -0
- package/src/screens/conversation_screen.tsx +11 -0
- package/src/screens/conversations/conversations_screen.tsx +3 -1
- package/src/types/product_analytics.ts +5 -0
- package/src/types/resources/analytics_metadata.ts +4 -0
- package/src/types/resources/conversation.ts +2 -0
- package/src/types/resources/index.ts +1 -0
- package/src/utils/__tests__/sha_256.test.ts +70 -0
- package/src/utils/request/conversation.ts +2 -1
- package/src/utils/sha_256.ts +229 -0
|
@@ -12,6 +12,7 @@ export const getConversationRequestArgs = ({ conversation_id }: { conversation_i
|
|
|
12
12
|
data: {
|
|
13
13
|
fields: {
|
|
14
14
|
Conversation: [
|
|
15
|
+
'analytics_metadata',
|
|
15
16
|
'created_at',
|
|
16
17
|
'badges',
|
|
17
18
|
'conversation_membership',
|
|
@@ -29,6 +30,7 @@ export const getConversationRequestArgs = ({ conversation_id }: { conversation_i
|
|
|
29
30
|
'unread_count',
|
|
30
31
|
'updated_at',
|
|
31
32
|
],
|
|
33
|
+
AnalyticsMetadata: ['metadata'],
|
|
32
34
|
MemberAbility: [
|
|
33
35
|
'can_update',
|
|
34
36
|
'can_delete',
|
|
@@ -40,12 +42,20 @@ export const getConversationRequestArgs = ({ conversation_id }: { conversation_i
|
|
|
40
42
|
ConversationBadge: ['app_name', 'pco_resource_type', 'text'],
|
|
41
43
|
Group: ['type', 'id', 'links', 'name', 'source_app_name', 'source_type'],
|
|
42
44
|
},
|
|
43
|
-
include: [
|
|
45
|
+
include: [
|
|
46
|
+
'badges',
|
|
47
|
+
'conversation_membership',
|
|
48
|
+
'member_ability',
|
|
49
|
+
'groups',
|
|
50
|
+
'analytics_metadata',
|
|
51
|
+
],
|
|
44
52
|
},
|
|
45
53
|
})
|
|
46
54
|
|
|
47
55
|
export const useConversation = ({ conversation_id }: { conversation_id: number }) => {
|
|
48
|
-
|
|
56
|
+
const args = getConversationRequestArgs({ conversation_id })
|
|
57
|
+
|
|
58
|
+
return useSuspenseGet<ConversationResource>(args)
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
export const useConversationMute = ({ conversation_id }: { conversation_id: number }) => {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
import { SHA256, enc } from '../utils/sha_256'
|
|
3
|
+
import { useChatContext } from '../contexts/chat_context'
|
|
4
|
+
import { ProductAnalyticsConfig } from '../types/product_analytics'
|
|
5
|
+
import { keysToSnakeCase } from '../utils/client/utils'
|
|
6
|
+
import { AnalyticsMetadataResource } from '../types/resources/analytics_metadata'
|
|
7
|
+
|
|
8
|
+
export interface EventMetadata {
|
|
9
|
+
[key: string]: string | number | boolean | null | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface AnalyticsEvent {
|
|
13
|
+
name: string
|
|
14
|
+
meta: EventMetadata
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function contentSha256(payload: string): string {
|
|
18
|
+
return SHA256(payload).toString(enc.Hex)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sendEvents(events: AnalyticsEvent[], config: ProductAnalyticsConfig): Promise<Response[]> {
|
|
22
|
+
// Split into chunks of 10 and make parallel requests
|
|
23
|
+
const chunks: AnalyticsEvent[][] = []
|
|
24
|
+
for (let i = 0; i < events.length; i += 10) {
|
|
25
|
+
chunks.push(events.slice(i, i + 10))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return Promise.all(
|
|
29
|
+
chunks.map(async chunk => {
|
|
30
|
+
const payload = JSON.stringify({ events: chunk })
|
|
31
|
+
return fetch(config.endpoint, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: {
|
|
34
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
35
|
+
'X-Authorization': `Bearer ${config.token}`,
|
|
36
|
+
'X-Amz-Content-Sha256': contentSha256(payload),
|
|
37
|
+
},
|
|
38
|
+
body: payload,
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const useProductAnalytics = () => {
|
|
45
|
+
const { productAnalyticsConfig } = useChatContext()
|
|
46
|
+
|
|
47
|
+
const snakeCasedBaseMeta = useMemo(
|
|
48
|
+
() => keysToSnakeCase(productAnalyticsConfig?.metadata || {}),
|
|
49
|
+
[productAnalyticsConfig?.metadata]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const publishEvent = useCallback(
|
|
53
|
+
(name: string, meta: EventMetadata = {}): Promise<Response[]> => {
|
|
54
|
+
if (!productAnalyticsConfig) {
|
|
55
|
+
if (__DEV__) {
|
|
56
|
+
console.warn('Product Analytics not available')
|
|
57
|
+
}
|
|
58
|
+
return Promise.resolve([])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const mergedMeta = { ...snakeCasedBaseMeta, ...meta } as EventMetadata
|
|
62
|
+
|
|
63
|
+
return sendEvents([{ name, meta: mergedMeta }], productAnalyticsConfig)
|
|
64
|
+
},
|
|
65
|
+
[productAnalyticsConfig, snakeCasedBaseMeta]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const publishEvents = useCallback(
|
|
69
|
+
(events: Array<{ name: string; meta?: EventMetadata }>): Promise<Response[]> => {
|
|
70
|
+
if (!productAnalyticsConfig) {
|
|
71
|
+
if (__DEV__) {
|
|
72
|
+
console.warn('Product Analytics not available')
|
|
73
|
+
}
|
|
74
|
+
return Promise.resolve([])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const eventsWithMeta = events.map(event => ({
|
|
78
|
+
name: event.name,
|
|
79
|
+
meta: { ...snakeCasedBaseMeta, ...event.meta } as EventMetadata,
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
return sendEvents(eventsWithMeta, productAnalyticsConfig)
|
|
83
|
+
},
|
|
84
|
+
[productAnalyticsConfig, snakeCasedBaseMeta]
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return { publishEvent, publishEvents }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function usePublishProductAnalyticsEvent(eventName: string, meta: EventMetadata = {}) {
|
|
91
|
+
const { publishEvent } = useProductAnalytics()
|
|
92
|
+
const hasPublishedEventRef = useRef(false)
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (hasPublishedEventRef.current) return
|
|
96
|
+
|
|
97
|
+
hasPublishedEventRef.current = true
|
|
98
|
+
|
|
99
|
+
publishEvent(eventName, meta)
|
|
100
|
+
}, [eventName, meta, publishEvent])
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function normalizeAnalyticsMetadata(resource: {
|
|
104
|
+
analyticsMetadata?: AnalyticsMetadataResource
|
|
105
|
+
}) {
|
|
106
|
+
return keysToSnakeCase(resource?.analyticsMetadata?.metadata || {}) as Record<string, unknown>
|
|
107
|
+
}
|
|
@@ -24,6 +24,10 @@ import { MessageForm } from '../components/conversation/message_form'
|
|
|
24
24
|
import { TypingIndicator } from '../components/conversation/typing_indicator'
|
|
25
25
|
import { KeyboardView } from '../components/display/keyboard_view'
|
|
26
26
|
import { useTheme } from '../hooks'
|
|
27
|
+
import {
|
|
28
|
+
normalizeAnalyticsMetadata,
|
|
29
|
+
usePublishProductAnalyticsEvent,
|
|
30
|
+
} from '../hooks/use_product_analytics'
|
|
27
31
|
import { useConversation } from '../hooks/use_conversation'
|
|
28
32
|
import { useConversationMessages } from '../hooks/use_conversation_messages'
|
|
29
33
|
import { useConversationMessagesJoltEvents } from '../hooks/use_conversation_messages_jolt_events'
|
|
@@ -56,6 +60,13 @@ export type ConversationScreenProps = StaticScreenProps<ConversationRouteProps>
|
|
|
56
60
|
export function ConversationScreen({ route }: ConversationScreenProps) {
|
|
57
61
|
const { conversation_id, reply_root_id } = route.params
|
|
58
62
|
|
|
63
|
+
const { data: conversation } = useConversation({ conversation_id })
|
|
64
|
+
|
|
65
|
+
usePublishProductAnalyticsEvent('chat.mobile.conversations.show.opened', {
|
|
66
|
+
reply_root_id,
|
|
67
|
+
...normalizeAnalyticsMetadata(conversation),
|
|
68
|
+
})
|
|
69
|
+
|
|
59
70
|
return (
|
|
60
71
|
<ConversationContextProvider
|
|
61
72
|
conversationId={conversation_id}
|
|
@@ -4,7 +4,7 @@ import { StyleSheet, View } from 'react-native'
|
|
|
4
4
|
import { Conversations, TextButton } from '../../components'
|
|
5
5
|
import { ActionButton } from '../../components/display/action_button'
|
|
6
6
|
import { ConversationsContextProvider } from '../../contexts/conversations_context'
|
|
7
|
-
import { useCanCreateConversations } from '../../hooks'
|
|
7
|
+
import { useCanCreateConversations, usePublishProductAnalyticsEvent } from '../../hooks'
|
|
8
8
|
import { AppName } from '../../types/resources/app_name'
|
|
9
9
|
import { GraphId } from '../../types/resources/group_resource'
|
|
10
10
|
import { destructureChatGroupGraphId, MAX_FONT_SIZE_MULTIPLIER_LANDMARK } from '../../utils'
|
|
@@ -60,6 +60,8 @@ export function ConversationsScreen({ route }: ConversationsScreenProps) {
|
|
|
60
60
|
navigation.navigate('GetHelp', { type: 'chat' })
|
|
61
61
|
}, [navigation])
|
|
62
62
|
|
|
63
|
+
usePublishProductAnalyticsEvent('chat.mobile.conversations.index.opened')
|
|
64
|
+
|
|
63
65
|
return (
|
|
64
66
|
<View style={styles.container}>
|
|
65
67
|
<ConversationsContextProvider args={route.params}>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AnalyticsMetadataResource } from './analytics_metadata'
|
|
1
2
|
import { ConversationBadgeResource } from './conversation_badge'
|
|
2
3
|
import { GroupResource } from './group_resource'
|
|
3
4
|
import { MemberAbilityResource } from './member_ability'
|
|
@@ -6,6 +7,7 @@ export interface ConversationResource {
|
|
|
6
7
|
type: 'Conversation'
|
|
7
8
|
id: number
|
|
8
9
|
badges?: ConversationBadgeResource[]
|
|
10
|
+
analyticsMetadata?: AnalyticsMetadataResource
|
|
9
11
|
conversationMembership?: {
|
|
10
12
|
lastReadMessageSortKey: string
|
|
11
13
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SHA256, enc } from '../sha_256'
|
|
2
|
+
|
|
3
|
+
describe('SHA256', () => {
|
|
4
|
+
it('computes correct hash for empty string', () => {
|
|
5
|
+
const hash = SHA256('').toString(enc.Hex)
|
|
6
|
+
expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('computes correct hash for "abc"', () => {
|
|
10
|
+
const hash = SHA256('abc').toString(enc.Hex)
|
|
11
|
+
expect(hash).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('computes correct hash for "The quick brown fox jumps over the lazy dog"', () => {
|
|
15
|
+
const hash = SHA256('The quick brown fox jumps over the lazy dog').toString(enc.Hex)
|
|
16
|
+
expect(hash).toBe('d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('computes correct hash for longer message', () => {
|
|
20
|
+
const message = 'abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq'
|
|
21
|
+
const hash = SHA256(message).toString(enc.Hex)
|
|
22
|
+
expect(hash).toBe('248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('computes correct hash for message with 1 million "a" characters', () => {
|
|
26
|
+
const message = 'a'.repeat(1000000)
|
|
27
|
+
const hash = SHA256(message).toString(enc.Hex)
|
|
28
|
+
expect(hash).toBe('cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('computes correct hash for UTF-8 string with emoji', () => {
|
|
32
|
+
const hash = SHA256('Hello 👋 World 🌍').toString(enc.Hex)
|
|
33
|
+
// This is the expected SHA-256 hash for this UTF-8 encoded string
|
|
34
|
+
expect(hash).toBe('c15ee319b97bfd23fa98590baf0b47d1f7b7b81f9e5bf99d8275867e2e4a32a2')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('computes correct hash for JSON payload', () => {
|
|
38
|
+
const payload = JSON.stringify({ events: [{ name: 'test', meta: { foo: 'bar' } }] })
|
|
39
|
+
const hash = SHA256(payload).toString(enc.Hex)
|
|
40
|
+
expect(hash).toBe('5e4043aadad336890b9795ee03675a9de1a6abbc4e84f9f95daaffb21569312e')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('toString without encoding parameter defaults to hex', () => {
|
|
44
|
+
const hash = SHA256('abc').toString()
|
|
45
|
+
expect(hash).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('produces consistent results for the same input', () => {
|
|
49
|
+
const input = 'test message'
|
|
50
|
+
const hash1 = SHA256(input).toString(enc.Hex)
|
|
51
|
+
const hash2 = SHA256(input).toString(enc.Hex)
|
|
52
|
+
expect(hash1).toBe(hash2)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('produces different hashes for different inputs', () => {
|
|
56
|
+
const hash1 = SHA256('message 1').toString(enc.Hex)
|
|
57
|
+
const hash2 = SHA256('message 2').toString(enc.Hex)
|
|
58
|
+
expect(hash1).not.toBe(hash2)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('handles strings with special characters', () => {
|
|
62
|
+
const hash = SHA256('!@#$%^&*()_+-=[]{}|;:",.<>?/~`').toString(enc.Hex)
|
|
63
|
+
expect(hash).toBe('67764597efaadfd3a9d89032105c9fcde16c6f05d16214e42488af193abb280a')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('handles strings with newlines and tabs', () => {
|
|
67
|
+
const hash = SHA256('line1\nline2\tline3').toString(enc.Hex)
|
|
68
|
+
expect(hash).toBe('450c0302bb6c2b9d81f81c8c868f7e96e3a35bc83751a08b7da09e77c4f13eec')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -44,9 +44,10 @@ export const getConversationsRequestArgs = ({
|
|
|
44
44
|
'unread_count',
|
|
45
45
|
'updated_at',
|
|
46
46
|
],
|
|
47
|
+
AnalyticsMetadata: ['metadata'],
|
|
47
48
|
ConversationBadge: ['app_name', 'pco_resource_type', 'text'],
|
|
48
49
|
},
|
|
49
|
-
include: ['badges'],
|
|
50
|
+
include: ['badges', 'analytics_metadata'],
|
|
50
51
|
},
|
|
51
52
|
}
|
|
52
53
|
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure JavaScript SHA-256 implementation
|
|
3
|
+
* Conforms to the crypto-js API for drop-in replacement
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* eslint-disable no-bitwise */
|
|
7
|
+
|
|
8
|
+
// SHA-256 constants (first 32 bits of the fractional parts of the cube roots of the first 64 primes)
|
|
9
|
+
const K = [
|
|
10
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
11
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
12
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
13
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
14
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
15
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
16
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
17
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
// Initial hash values (first 32 bits of the fractional parts of the square roots of the first 8 primes)
|
|
21
|
+
const H = [
|
|
22
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/** Right rotate 32-bit integer */
|
|
26
|
+
function rotr(n: number, x: number): number {
|
|
27
|
+
return (x >>> n) | (x << (32 - n))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** SHA-256 choice function */
|
|
31
|
+
function ch(x: number, y: number, z: number): number {
|
|
32
|
+
return (x & y) ^ (~x & z)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** SHA-256 majority function */
|
|
36
|
+
function maj(x: number, y: number, z: number): number {
|
|
37
|
+
return (x & y) ^ (x & z) ^ (y & z)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** SHA-256 Σ0 function */
|
|
41
|
+
function sigma0(x: number): number {
|
|
42
|
+
return rotr(2, x) ^ rotr(13, x) ^ rotr(22, x)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** SHA-256 Σ1 function */
|
|
46
|
+
function sigma1(x: number): number {
|
|
47
|
+
return rotr(6, x) ^ rotr(11, x) ^ rotr(25, x)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** SHA-256 σ0 function */
|
|
51
|
+
function gamma0(x: number): number {
|
|
52
|
+
return rotr(7, x) ^ rotr(18, x) ^ (x >>> 3)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** SHA-256 σ1 function */
|
|
56
|
+
function gamma1(x: number): number {
|
|
57
|
+
return rotr(17, x) ^ rotr(19, x) ^ (x >>> 10)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Convert string to UTF-8 byte array */
|
|
61
|
+
function stringToBytes(str: string): number[] {
|
|
62
|
+
const bytes: number[] = []
|
|
63
|
+
for (let i = 0; i < str.length; i++) {
|
|
64
|
+
let charCode = str.charCodeAt(i)
|
|
65
|
+
if (charCode < 0x80) {
|
|
66
|
+
bytes.push(charCode)
|
|
67
|
+
} else if (charCode < 0x800) {
|
|
68
|
+
bytes.push(0xc0 | (charCode >> 6), 0x80 | (charCode & 0x3f))
|
|
69
|
+
} else if (charCode < 0xd800 || charCode >= 0xe000) {
|
|
70
|
+
bytes.push(0xe0 | (charCode >> 12), 0x80 | ((charCode >> 6) & 0x3f), 0x80 | (charCode & 0x3f))
|
|
71
|
+
} else {
|
|
72
|
+
// Surrogate pair
|
|
73
|
+
i++
|
|
74
|
+
charCode = 0x10000 + (((charCode & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff))
|
|
75
|
+
bytes.push(
|
|
76
|
+
0xf0 | (charCode >> 18),
|
|
77
|
+
0x80 | ((charCode >> 12) & 0x3f),
|
|
78
|
+
0x80 | ((charCode >> 6) & 0x3f),
|
|
79
|
+
0x80 | (charCode & 0x3f)
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return bytes
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Preprocess message: pad and convert to 512-bit blocks */
|
|
87
|
+
function preprocessMessage(message: number[]): Uint32Array {
|
|
88
|
+
const messageLength = message.length
|
|
89
|
+
const bitLength = messageLength * 8
|
|
90
|
+
|
|
91
|
+
// Append the '1' bit (plus zero padding to make 8 bits)
|
|
92
|
+
message.push(0x80)
|
|
93
|
+
|
|
94
|
+
// Append zeros until message length ≡ 448 (mod 512)
|
|
95
|
+
// This leaves 64 bits for the length
|
|
96
|
+
while (message.length % 64 !== 56) {
|
|
97
|
+
message.push(0x00)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Append original message length as 64-bit big-endian integer
|
|
101
|
+
// JavaScript numbers are 53-bit safe, so we split into two 32-bit parts
|
|
102
|
+
const lengthHigh = Math.floor(bitLength / 0x100000000)
|
|
103
|
+
const lengthLow = bitLength >>> 0
|
|
104
|
+
|
|
105
|
+
for (let i = 24; i >= 0; i -= 8) {
|
|
106
|
+
message.push((lengthHigh >>> i) & 0xff)
|
|
107
|
+
}
|
|
108
|
+
for (let i = 24; i >= 0; i -= 8) {
|
|
109
|
+
message.push((lengthLow >>> i) & 0xff)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Convert byte array to 32-bit word array
|
|
113
|
+
const words = new Uint32Array(message.length / 4)
|
|
114
|
+
for (let i = 0; i < message.length; i += 4) {
|
|
115
|
+
words[i / 4] =
|
|
116
|
+
(message[i] << 24) | (message[i + 1] << 16) | (message[i + 2] << 8) | message[i + 3]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return words
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Process a single 512-bit chunk */
|
|
123
|
+
function processChunk(chunk: Uint32Array, hash: number[]): void {
|
|
124
|
+
const W = new Uint32Array(64)
|
|
125
|
+
|
|
126
|
+
// Prepare message schedule
|
|
127
|
+
for (let t = 0; t < 16; t++) {
|
|
128
|
+
W[t] = chunk[t]
|
|
129
|
+
}
|
|
130
|
+
for (let t = 16; t < 64; t++) {
|
|
131
|
+
W[t] = (gamma1(W[t - 2]) + W[t - 7] + gamma0(W[t - 15]) + W[t - 16]) >>> 0
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Initialize working variables
|
|
135
|
+
let a = hash[0]
|
|
136
|
+
let b = hash[1]
|
|
137
|
+
let c = hash[2]
|
|
138
|
+
let d = hash[3]
|
|
139
|
+
let e = hash[4]
|
|
140
|
+
let f = hash[5]
|
|
141
|
+
let g = hash[6]
|
|
142
|
+
let h = hash[7]
|
|
143
|
+
|
|
144
|
+
// Main loop
|
|
145
|
+
for (let t = 0; t < 64; t++) {
|
|
146
|
+
const T1 = (h + sigma1(e) + ch(e, f, g) + K[t] + W[t]) >>> 0
|
|
147
|
+
const T2 = (sigma0(a) + maj(a, b, c)) >>> 0
|
|
148
|
+
h = g
|
|
149
|
+
g = f
|
|
150
|
+
f = e
|
|
151
|
+
e = (d + T1) >>> 0
|
|
152
|
+
d = c
|
|
153
|
+
c = b
|
|
154
|
+
b = a
|
|
155
|
+
a = (T1 + T2) >>> 0
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Add compressed chunk to current hash value
|
|
159
|
+
hash[0] = (hash[0] + a) >>> 0
|
|
160
|
+
hash[1] = (hash[1] + b) >>> 0
|
|
161
|
+
hash[2] = (hash[2] + c) >>> 0
|
|
162
|
+
hash[3] = (hash[3] + d) >>> 0
|
|
163
|
+
hash[4] = (hash[4] + e) >>> 0
|
|
164
|
+
hash[5] = (hash[5] + f) >>> 0
|
|
165
|
+
hash[6] = (hash[6] + g) >>> 0
|
|
166
|
+
hash[7] = (hash[7] + h) >>> 0
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Compute SHA-256 hash and return as byte array */
|
|
170
|
+
function sha256Raw(message: string): number[] {
|
|
171
|
+
const bytes = stringToBytes(message)
|
|
172
|
+
const preprocessed = preprocessMessage(bytes)
|
|
173
|
+
const hash = [...H] // Copy initial hash values
|
|
174
|
+
|
|
175
|
+
// Process each 512-bit chunk
|
|
176
|
+
for (let i = 0; i < preprocessed.length; i += 16) {
|
|
177
|
+
const chunk = preprocessed.subarray(i, i + 16)
|
|
178
|
+
processChunk(chunk, hash)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Convert hash to byte array
|
|
182
|
+
const result: number[] = []
|
|
183
|
+
for (let i = 0; i < 8; i++) {
|
|
184
|
+
result.push((hash[i] >>> 24) & 0xff)
|
|
185
|
+
result.push((hash[i] >>> 16) & 0xff)
|
|
186
|
+
result.push((hash[i] >>> 8) & 0xff)
|
|
187
|
+
result.push(hash[i] & 0xff)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return result
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Hash result object with toString methods */
|
|
194
|
+
class HashResult {
|
|
195
|
+
private bytes: number[]
|
|
196
|
+
|
|
197
|
+
constructor(bytes: number[]) {
|
|
198
|
+
this.bytes = bytes
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Convert hash to hexadecimal string */
|
|
202
|
+
toString(encoding?: { stringify: (bytes: number[]) => string }): string {
|
|
203
|
+
if (encoding === Hex) {
|
|
204
|
+
return this.bytes.map(b => b.toString(16).padStart(2, '0')).join('')
|
|
205
|
+
}
|
|
206
|
+
// Default to hex if no encoding specified
|
|
207
|
+
return this.bytes.map(b => b.toString(16).padStart(2, '0')).join('')
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Hex encoding object (mimics crypto-js enc.Hex) */
|
|
212
|
+
export const Hex = {
|
|
213
|
+
stringify: (bytes: number[]) => bytes.map(b => b.toString(16).padStart(2, '0')).join(''),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Compute SHA-256 hash of a string
|
|
218
|
+
* @param message - The message to hash
|
|
219
|
+
* @returns HashResult object with toString method
|
|
220
|
+
*/
|
|
221
|
+
export function SHA256(message: string): HashResult {
|
|
222
|
+
const hash = sha256Raw(message)
|
|
223
|
+
return new HashResult(hash)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Encoding utilities (mimics crypto-js enc) */
|
|
227
|
+
export const enc = {
|
|
228
|
+
Hex,
|
|
229
|
+
}
|