@financial-times/qanda-ui 0.0.1-beta.1
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/.prettierrc +3 -0
- package/.toolkitrc.yml +34 -0
- package/.vscode/settings.json +4 -0
- package/CODEOWNERS +3 -0
- package/README.md +161 -0
- package/babel.config.json +10 -0
- package/dist/index.html +4 -0
- package/dist/index.js +2 -0
- package/dist/index.js.LICENSE.txt +15 -0
- package/dist/mockServiceWorker.js +307 -0
- package/jest.setup.ts +15 -0
- package/package.json +115 -0
- package/server.js +17 -0
- package/src/client/styles.scss +61 -0
- package/src/components/Author.tsx +45 -0
- package/src/components/CountdownTimer.tsx +73 -0
- package/src/components/Network.tsx +19 -0
- package/src/components/Qanda.tsx +51 -0
- package/src/components/QandaBlock.tsx +63 -0
- package/src/components/QandaContainer.tsx +60 -0
- package/src/components/QandaOComments.tsx +60 -0
- package/src/components/QandaProvider.tsx +13 -0
- package/src/components/QuestionForm.tsx +122 -0
- package/src/components/Status.tsx +20 -0
- package/src/components/Stream.tsx +49 -0
- package/src/components/UITestBar.tsx +76 -0
- package/src/components/UpdatesButton.tsx +45 -0
- package/src/components/UserInfo.tsx +57 -0
- package/src/index.tsx +58 -0
- package/src/services/comments-api.ts +217 -0
- package/src/services/custom-sse.ts +101 -0
- package/src/services/mocks/api-endpoints.ts +36 -0
- package/src/services/mocks/mock-comments.json +307 -0
- package/src/services/mocks/msw-browser.ts +4 -0
- package/src/services/mocks/msw-server.ts +4 -0
- package/src/store/index.ts +41 -0
- package/src/store/network.ts +23 -0
- package/src/store/qa.ts +22 -0
- package/src/store/questionSlice.ts +37 -0
- package/src/store/stream.ts +21 -0
- package/src/store/updateComments.ts +60 -0
- package/src/store/user.ts +55 -0
- package/src/types/async-operation.d.ts +4 -0
- package/src/types/comment.d.ts +19 -0
- package/src/types/globals.d.ts +1 -0
- package/src/types/modules.d.ts +1 -0
- package/src/types/o-types.d.ts +2 -0
- package/src/types/qanda.d.ts +7 -0
- package/src/types/sse-events.d.ts +6 -0
- package/src/types/x-dash.d.ts +6 -0
- package/src/utils/auth.ts +9 -0
- package/src/utils/mocks/update.ts +168 -0
- package/src/utils/qandas.ts +21 -0
- package/tsconfig.json +26 -0
- package/webpack.common.js +60 -0
- package/webpack.dev.js +26 -0
- package/webpack.prod.js +6 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createApi,
|
|
3
|
+
fetchBaseQuery,
|
|
4
|
+
FetchBaseQueryError,
|
|
5
|
+
} from '@reduxjs/toolkit/query/react';
|
|
6
|
+
|
|
7
|
+
import customSSE from './custom-sse';
|
|
8
|
+
import type { Comment } from '../types/comment';
|
|
9
|
+
import { NewAnswerEvent } from '../types/sse-events';
|
|
10
|
+
|
|
11
|
+
// TODO: ensure that users don't miss out on updates in this window
|
|
12
|
+
// https://financialtimes.atlassian.net/browse/CI-2715
|
|
13
|
+
const retryDelay = 1000;
|
|
14
|
+
|
|
15
|
+
const updateTypes = {
|
|
16
|
+
QA_NEW_REPLY: 'updatedComments',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export let eventSource: EventSource | null = null;
|
|
20
|
+
|
|
21
|
+
type CommentsStreamResponse = {
|
|
22
|
+
type: string;
|
|
23
|
+
children?: Comment[];
|
|
24
|
+
startingDateTime?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type UpdatesResponse = {
|
|
28
|
+
type: string;
|
|
29
|
+
payload: any; // maybe unknown?
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type CachedData = {
|
|
33
|
+
updatedComments?: Comment[];
|
|
34
|
+
[key: string]: any; //fallback
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const nextCommentsApi = createApi({
|
|
38
|
+
reducerPath: 'nextCommentsApi',
|
|
39
|
+
refetchOnReconnect: true, // refetches all subscribed queries on regaining a network connection
|
|
40
|
+
baseQuery: fetchBaseQuery({
|
|
41
|
+
timeout: 10000,
|
|
42
|
+
}),
|
|
43
|
+
endpoints: (builder) => ({
|
|
44
|
+
// QA STREAM
|
|
45
|
+
getQandAStream: builder.query({
|
|
46
|
+
query: ({
|
|
47
|
+
storyId,
|
|
48
|
+
useStaging,
|
|
49
|
+
commentsAPIUrl,
|
|
50
|
+
}: {
|
|
51
|
+
storyId: string;
|
|
52
|
+
useStaging: boolean;
|
|
53
|
+
commentsAPIUrl: string;
|
|
54
|
+
}) =>
|
|
55
|
+
`${commentsAPIUrl}/story/${storyId}/stream${useStaging ? '?staging=1' : ''}`,
|
|
56
|
+
transformResponse: (response: CommentsStreamResponse) => {
|
|
57
|
+
return {
|
|
58
|
+
qandas: response?.children || [],
|
|
59
|
+
type: response.type,
|
|
60
|
+
startingDateTime: response?.startingDateTime || '',
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
// UPDATES LISTENER
|
|
65
|
+
getQandAUpdates: builder.query({
|
|
66
|
+
queryFn: async ({
|
|
67
|
+
storyId,
|
|
68
|
+
useStaging,
|
|
69
|
+
commentsAPIUrl,
|
|
70
|
+
}: {
|
|
71
|
+
storyId: string;
|
|
72
|
+
useStaging: boolean;
|
|
73
|
+
commentsAPIUrl: string;
|
|
74
|
+
}) => {
|
|
75
|
+
return { data: {} as CachedData };
|
|
76
|
+
},
|
|
77
|
+
async onCacheEntryAdded(
|
|
78
|
+
arg,
|
|
79
|
+
{ updateCachedData, cacheDataLoaded, cacheEntryRemoved },
|
|
80
|
+
) {
|
|
81
|
+
await cacheDataLoaded;
|
|
82
|
+
let connectToSSE: () => void;
|
|
83
|
+
try {
|
|
84
|
+
connectToSSE = () => {
|
|
85
|
+
const sseEndpoint = `${arg.commentsAPIUrl}/story/${arg.storyId}/updates${arg.useStaging ? '?staging=1' : ''}`;
|
|
86
|
+
eventSource = new customSSE(sseEndpoint);
|
|
87
|
+
|
|
88
|
+
eventSource.onopen = () => {
|
|
89
|
+
console.log(`Connected to SSE: ${sseEndpoint}`);
|
|
90
|
+
};
|
|
91
|
+
eventSource.onmessage = (event) => {
|
|
92
|
+
try {
|
|
93
|
+
const data: NewAnswerEvent = JSON.parse(event.data);
|
|
94
|
+
console.log('Received SSE update:', data);
|
|
95
|
+
if (data && data.type) {
|
|
96
|
+
updateCachedData((draft: CachedData) => {
|
|
97
|
+
const updatedData = updateTypes[data.type];
|
|
98
|
+
draft[updatedData] = draft[updatedData] || [];
|
|
99
|
+
draft[updatedData].push(data.payload);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.warn('Failed to parse SSE message:', event.data);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
eventSource.onerror = (error) => {
|
|
107
|
+
console.error(
|
|
108
|
+
`SSE error occurred. Reconnecting in ${retryDelay / 1000} seconds...`,
|
|
109
|
+
);
|
|
110
|
+
eventSource?.close();
|
|
111
|
+
eventSource = null;
|
|
112
|
+
|
|
113
|
+
setTimeout(() => connectToSSE(), retryDelay);
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
connectToSSE();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error('Failed to subscribe to SSE:', error);
|
|
119
|
+
console.error(`Reconnecting in ${retryDelay / 1000} seconds...`);
|
|
120
|
+
eventSource = null;
|
|
121
|
+
setTimeout(() => connectToSSE(), retryDelay);
|
|
122
|
+
}
|
|
123
|
+
// cleanup when there are no subscribers to the cache anymore
|
|
124
|
+
await cacheEntryRemoved;
|
|
125
|
+
if (eventSource !== null) {
|
|
126
|
+
eventSource.close();
|
|
127
|
+
eventSource = null;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
// USER ROLES
|
|
132
|
+
getUserRoles: builder.query({
|
|
133
|
+
query: ({
|
|
134
|
+
storyId,
|
|
135
|
+
useStaging,
|
|
136
|
+
commentsAPIUrl,
|
|
137
|
+
token,
|
|
138
|
+
}: {
|
|
139
|
+
storyId: string;
|
|
140
|
+
useStaging: boolean;
|
|
141
|
+
commentsAPIUrl: string;
|
|
142
|
+
token: string;
|
|
143
|
+
}) => ({
|
|
144
|
+
url: `${commentsAPIUrl}/user/user-details/story/${storyId}${useStaging ? '?staging=1' : ''}`,
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: {
|
|
147
|
+
Authorization: `Bearer ${token}`,
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
}),
|
|
151
|
+
// POST A QUESTION
|
|
152
|
+
postQuestion: builder.mutation({
|
|
153
|
+
query: ({
|
|
154
|
+
storyId,
|
|
155
|
+
useStaging,
|
|
156
|
+
commentsAPIUrl,
|
|
157
|
+
token,
|
|
158
|
+
question,
|
|
159
|
+
}: {
|
|
160
|
+
storyId: string;
|
|
161
|
+
useStaging: boolean;
|
|
162
|
+
commentsAPIUrl: string;
|
|
163
|
+
token: string;
|
|
164
|
+
question: string;
|
|
165
|
+
}) => ({
|
|
166
|
+
url: `${commentsAPIUrl}/story/${storyId}/question${useStaging ? '?staging=1' : ''}`,
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: {
|
|
169
|
+
'Content-Type': 'application/json',
|
|
170
|
+
Authorization: `Bearer ${token}`,
|
|
171
|
+
},
|
|
172
|
+
body: JSON.stringify({ question, token }),
|
|
173
|
+
}),
|
|
174
|
+
transformResponse: (response: { data: any }, meta, arg) => {
|
|
175
|
+
return response.data;
|
|
176
|
+
},
|
|
177
|
+
transformErrorResponse: (response: FetchBaseQueryError, meta, arg) => {
|
|
178
|
+
if (response.data && typeof response.data === 'object') {
|
|
179
|
+
const errorResponse = response.data as { error?: string };
|
|
180
|
+
return errorResponse.error
|
|
181
|
+
? errorResponse.error
|
|
182
|
+
: 'An unknown error occurred';
|
|
183
|
+
}
|
|
184
|
+
return 'An unknown error occurred';
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
}),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
export const {
|
|
191
|
+
useGetQandAStreamQuery,
|
|
192
|
+
useGetQandAUpdatesQuery,
|
|
193
|
+
useGetUserRolesQuery,
|
|
194
|
+
usePostQuestionMutation,
|
|
195
|
+
} = nextCommentsApi;
|
|
196
|
+
|
|
197
|
+
// UPDATES QUERY CUSTOM HOOKS
|
|
198
|
+
|
|
199
|
+
// custom hook for getting just the updated comments from the updates, so we are more efficient
|
|
200
|
+
export const useUpdatedComments = ({
|
|
201
|
+
storyId,
|
|
202
|
+
useStaging,
|
|
203
|
+
commentsAPIUrl,
|
|
204
|
+
}: {
|
|
205
|
+
storyId: string;
|
|
206
|
+
useStaging: boolean;
|
|
207
|
+
commentsAPIUrl: string;
|
|
208
|
+
}) => {
|
|
209
|
+
return nextCommentsApi.useGetQandAUpdatesQuery(
|
|
210
|
+
{ storyId, useStaging, commentsAPIUrl },
|
|
211
|
+
{
|
|
212
|
+
selectFromResult: ({ data }) => ({
|
|
213
|
+
updatedComments: data?.updatedComments || [],
|
|
214
|
+
}),
|
|
215
|
+
},
|
|
216
|
+
).updatedComments;
|
|
217
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const MOCK_SSE =
|
|
2
|
+
process.env.NODE_ENV === 'development'
|
|
3
|
+
? require('../../demo/config').MOCK_SSE
|
|
4
|
+
: false;
|
|
5
|
+
|
|
6
|
+
interface EventSourceInit {
|
|
7
|
+
withCredentials?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface EventSourceEventMap {
|
|
11
|
+
message: MessageEvent;
|
|
12
|
+
open: Event;
|
|
13
|
+
error: Event;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class MockEventSource {
|
|
17
|
+
static readonly CONNECTING = 0;
|
|
18
|
+
static readonly OPEN = 1;
|
|
19
|
+
static readonly CLOSED = 2;
|
|
20
|
+
|
|
21
|
+
readonly CONNECTING = MockEventSource.CONNECTING;
|
|
22
|
+
readonly OPEN = MockEventSource.OPEN;
|
|
23
|
+
readonly CLOSED = MockEventSource.CLOSED;
|
|
24
|
+
|
|
25
|
+
readyState: number = MockEventSource.CLOSED;
|
|
26
|
+
withCredentials: boolean = false;
|
|
27
|
+
url: string;
|
|
28
|
+
onopen: ((this: EventSource, ev: Event) => any) | null = null;
|
|
29
|
+
onmessage: ((this: EventSource, ev: MessageEvent) => any) | null = null;
|
|
30
|
+
onerror: ((this: EventSource, ev: Event) => any) | null = null;
|
|
31
|
+
isClosed = false;
|
|
32
|
+
|
|
33
|
+
constructor(url: string | URL, eventSourceInitDict?: EventSourceInit) {
|
|
34
|
+
this.url = url.toString();
|
|
35
|
+
if (eventSourceInitDict?.withCredentials) {
|
|
36
|
+
this.withCredentials = eventSourceInitDict.withCredentials;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Simulate an async connection opening
|
|
40
|
+
setTimeout(() => {
|
|
41
|
+
if (this.onopen && !this.isClosed) {
|
|
42
|
+
this.onopen(new Event('open'));
|
|
43
|
+
}
|
|
44
|
+
}, 500);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
addEventListener<K extends keyof EventSourceEventMap>(
|
|
48
|
+
type: K,
|
|
49
|
+
listener: (this: EventSource, ev: EventSourceEventMap[K]) => any,
|
|
50
|
+
options?: boolean | AddEventListenerOptions,
|
|
51
|
+
): void {
|
|
52
|
+
// Mock implementation when needed
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
removeEventListener<K extends keyof EventSourceEventMap>(
|
|
56
|
+
type: K,
|
|
57
|
+
listener: (this: EventSource, ev: EventSourceEventMap[K]) => any,
|
|
58
|
+
options?: boolean | EventListenerOptions,
|
|
59
|
+
): void {
|
|
60
|
+
// Mock implementation when needed
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Simulate receiving a message from the SSE server.
|
|
65
|
+
*/
|
|
66
|
+
sendMessage(data: any) {
|
|
67
|
+
if (this.onmessage && !this.isClosed) {
|
|
68
|
+
this.onmessage(
|
|
69
|
+
new MessageEvent('message', { data: JSON.stringify(data) }),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Simulate an SSE error.
|
|
76
|
+
*/
|
|
77
|
+
simulateError() {
|
|
78
|
+
if (this.onerror && !this.isClosed) {
|
|
79
|
+
this.onerror(new Event('error'));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Close the event source.
|
|
85
|
+
*/
|
|
86
|
+
close() {
|
|
87
|
+
this.isClosed = true;
|
|
88
|
+
this.readyState = MockEventSource.CLOSED;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
dispatchEvent(event: Event): boolean {
|
|
92
|
+
// Mock implementation
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const customSSE: typeof MockEventSource | typeof EventSource = MOCK_SSE
|
|
98
|
+
? MockEventSource
|
|
99
|
+
: EventSource;
|
|
100
|
+
|
|
101
|
+
export default customSSE;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { http, HttpResponse } from 'msw';
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
3
|
+
const mockComments = require('./mock-comments.json');
|
|
4
|
+
|
|
5
|
+
export const handlers = [
|
|
6
|
+
http.get('*/story/:storyId/stream', (req) => {
|
|
7
|
+
return HttpResponse.json(mockComments);
|
|
8
|
+
}),
|
|
9
|
+
http.get('*/story/:storyId/updates', (req) => {
|
|
10
|
+
// NB: this is probably not the correct response, but it's just a placeholder for now
|
|
11
|
+
return HttpResponse.json({
|
|
12
|
+
updatedComments: [
|
|
13
|
+
{
|
|
14
|
+
id: 'mocked-update-id',
|
|
15
|
+
body: { structured: { tree: { type: 'body', children: [] } } },
|
|
16
|
+
publishedDate: '2024-01-24T02:51:49.440Z',
|
|
17
|
+
byline: { structured: { tree: { type: 'byline', children: [] } } },
|
|
18
|
+
author: { role: 'Mocked Author' },
|
|
19
|
+
children: [],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
}),
|
|
24
|
+
http.post('*/user/user-details/story/:storyId', (req) => {
|
|
25
|
+
return HttpResponse.json({
|
|
26
|
+
isExpert: true,
|
|
27
|
+
isStaffExpert: true,
|
|
28
|
+
role: 'ADMIN',
|
|
29
|
+
});
|
|
30
|
+
}),
|
|
31
|
+
http.post('*/story/:storyId/question', (req) => {
|
|
32
|
+
return HttpResponse.json({
|
|
33
|
+
data: 'Question submitted successfully!',
|
|
34
|
+
});
|
|
35
|
+
}),
|
|
36
|
+
];
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "qa",
|
|
3
|
+
"startingDateTime": "2025-02-28T21:19:07.877Z",
|
|
4
|
+
"children": [
|
|
5
|
+
{
|
|
6
|
+
"type": "comment",
|
|
7
|
+
"id": "37c43f17-cf7a-485c-9269-e705da9ad784",
|
|
8
|
+
"title": "",
|
|
9
|
+
"body": {
|
|
10
|
+
"structured": {
|
|
11
|
+
"tree": {
|
|
12
|
+
"type": "body",
|
|
13
|
+
"version": 1,
|
|
14
|
+
"children": [
|
|
15
|
+
{
|
|
16
|
+
"type": "paragraph",
|
|
17
|
+
"children": [
|
|
18
|
+
{
|
|
19
|
+
"type": "text",
|
|
20
|
+
"value": "Can the creator of Bitcoin / other cryptocurrencies change the maximum number of tokens that will be produced as to what is stated initially in its white paper?"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"references": []
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"publishedDate": "2024-01-24T02:51:49.440Z",
|
|
30
|
+
"isQuestion": true,
|
|
31
|
+
"isAnswer": false,
|
|
32
|
+
"byline": "Robin C, London",
|
|
33
|
+
"children": [
|
|
34
|
+
{
|
|
35
|
+
"type": "comment",
|
|
36
|
+
"id": "aac43f17-cf5a-485c-9269-e705da9ad784",
|
|
37
|
+
"title": "",
|
|
38
|
+
"body": {
|
|
39
|
+
"structured": {
|
|
40
|
+
"tree": {
|
|
41
|
+
"type": "comment",
|
|
42
|
+
"version": 1,
|
|
43
|
+
"children": [
|
|
44
|
+
{
|
|
45
|
+
"type": "paragraph",
|
|
46
|
+
"children": [
|
|
47
|
+
{
|
|
48
|
+
"type": "text",
|
|
49
|
+
"value": "It's true that only a handful of people can approve whatever pull requests you or I might care to submit to a GitHub repository. However this element of centralization is offset by three things:—"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"type": "list",
|
|
53
|
+
"children": [
|
|
54
|
+
{
|
|
55
|
+
"type": "list-item",
|
|
56
|
+
"children": [
|
|
57
|
+
{
|
|
58
|
+
"type": "text",
|
|
59
|
+
"value": "The miners are free to accept "
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"type": "emphasis",
|
|
63
|
+
"children": [
|
|
64
|
+
{
|
|
65
|
+
"type": "text",
|
|
66
|
+
"value": "or ignore"
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"type": "text",
|
|
72
|
+
"value": " the actions of the maintainers. The miners can download old versions if they wish (since git records all changes); or they can download their software from a rival repository (which a dissenting group of "
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"type": "emphasis",
|
|
76
|
+
"children": [
|
|
77
|
+
{
|
|
78
|
+
"type": "text",
|
|
79
|
+
"value": "other"
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"type": "text",
|
|
85
|
+
"value": " maintainers would set up by cloning the original)"
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"type": "list-item",
|
|
91
|
+
"children": [
|
|
92
|
+
{
|
|
93
|
+
"type": "text",
|
|
94
|
+
"value": "Every active Bitcoin developer (including dissenters) has a personal copy of the whole repository (including its history of changes) on his/her own computer — that's how git works. GitHub is just a centralised point (owned by Microsoft) that individual developers can use (or not use) to synchronise their own personal git repositories against other individual developers’."
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
"references": []
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"isQuestion": false,
|
|
108
|
+
"isAnswer": true,
|
|
109
|
+
"publishedDate": "2024-01-24T02:53:19.440Z",
|
|
110
|
+
"children": [],
|
|
111
|
+
"byline": {
|
|
112
|
+
"tree": {
|
|
113
|
+
"type": "byline",
|
|
114
|
+
"children": [
|
|
115
|
+
{
|
|
116
|
+
"type": "author",
|
|
117
|
+
"id": "d24990d1-2aec-4d72-8330-cb1c73532f49",
|
|
118
|
+
"children": [
|
|
119
|
+
{
|
|
120
|
+
"type": "text",
|
|
121
|
+
"value": "Rana Foroohar"
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
"data": {
|
|
125
|
+
"referenceIndex": 0
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
"references": [
|
|
131
|
+
{
|
|
132
|
+
"concept": {
|
|
133
|
+
"url": "https://www.ft.com/stream/d24990d1-2aec-4d72-8330-cb1c73532f49",
|
|
134
|
+
"relativeUrl": "/stream/d24990d1-2aec-4d72-8330-cb1c73532f49"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
"author": {
|
|
140
|
+
"role": "FT Reporter",
|
|
141
|
+
"headshot": "https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fd1e00ek4ebabms.cloudfront.net%2Fproduction%2Fuploaded-files%2FRanaFaroohar-byline_cutout-48f06865-5fb8-49e2-99f1-e2781d64d566.png?source=next-article&fit=scale-down&quality=highest&width=150&dpr=2",
|
|
142
|
+
"prefLabel": "Rana Foroohar",
|
|
143
|
+
"streamPage": "https://www.ft.com/stream/d24990d1-2aec-4d72-8330-cb1c73532f49"
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"type": "comment",
|
|
148
|
+
"id": "bbc43f17-cf7a-485c-9269-e705da9ad684",
|
|
149
|
+
"title": "",
|
|
150
|
+
"body": {
|
|
151
|
+
"structured": {
|
|
152
|
+
"tree": {
|
|
153
|
+
"type": "comment",
|
|
154
|
+
"version": 1,
|
|
155
|
+
"children": [
|
|
156
|
+
{
|
|
157
|
+
"type": "paragraph",
|
|
158
|
+
"children": [
|
|
159
|
+
{
|
|
160
|
+
"type": "text",
|
|
161
|
+
"value": "The source code for Bitcoin is published on GitHub. If you want to increase the number of tokens, here are some instructions:—"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"type": "list",
|
|
165
|
+
"children": [
|
|
166
|
+
{
|
|
167
|
+
"type": "list-item",
|
|
168
|
+
"children": [
|
|
169
|
+
{
|
|
170
|
+
"type": "text",
|
|
171
|
+
"value": "Open a GitHub account (free)."
|
|
172
|
+
}
|
|
173
|
+
]
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"type": "list-item",
|
|
177
|
+
"children": [
|
|
178
|
+
{
|
|
179
|
+
"type": "text",
|
|
180
|
+
"value": "Click the “clone me” button on the Bitcoin repository page to create your own copy of the Bitcoin source code. We'll call your new repository “BitcoinDither”."
|
|
181
|
+
}
|
|
182
|
+
]
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"type": "list-item",
|
|
186
|
+
"children": [
|
|
187
|
+
{
|
|
188
|
+
"type": "text",
|
|
189
|
+
"value": "Change the line(s) of code in your new BitcoinDither repository that specify the maximum number of tokens."
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
}
|
|
193
|
+
]
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
]
|
|
198
|
+
},
|
|
199
|
+
"references": []
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
"isQuestion": false,
|
|
203
|
+
"isAnswer": true,
|
|
204
|
+
"publishedDate": "2024-01-24T02:54:59.440Z",
|
|
205
|
+
"children": [],
|
|
206
|
+
"byline": {
|
|
207
|
+
"tree": {
|
|
208
|
+
"type": "byline",
|
|
209
|
+
"children": [
|
|
210
|
+
{
|
|
211
|
+
"type": "author",
|
|
212
|
+
"id": "e191658e-c66a-45bc-9bad-343bdc4210b3",
|
|
213
|
+
"children": [
|
|
214
|
+
{
|
|
215
|
+
"type": "text",
|
|
216
|
+
"value": "John Burn-Murdoch"
|
|
217
|
+
}
|
|
218
|
+
],
|
|
219
|
+
"data": {
|
|
220
|
+
"referenceIndex": 0
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
]
|
|
224
|
+
},
|
|
225
|
+
"references": [
|
|
226
|
+
{
|
|
227
|
+
"concept": {
|
|
228
|
+
"url": "https://www.ft.com/stream/e191658e-c66a-45bc-9bad-343bdc4210b3",
|
|
229
|
+
"relativeUrl": "/stream/e191658e-c66a-45bc-9bad-343bdc4210b3"
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
]
|
|
233
|
+
},
|
|
234
|
+
"author": {
|
|
235
|
+
"role": "FT Reporter",
|
|
236
|
+
"streamPage": "https://www.ft.com/stream/e191658e-c66a-45bc-9bad-343bdc4210b3"
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
"type": "comment",
|
|
241
|
+
"id": "bbc43f17-cf7a-485c-9269-e705da9ad111",
|
|
242
|
+
"title": "",
|
|
243
|
+
"body": {
|
|
244
|
+
"structured": {
|
|
245
|
+
"tree": {
|
|
246
|
+
"type": "comment",
|
|
247
|
+
"version": 1,
|
|
248
|
+
"children": [
|
|
249
|
+
{
|
|
250
|
+
"type": "paragraph",
|
|
251
|
+
"children": [
|
|
252
|
+
{
|
|
253
|
+
"type": "text",
|
|
254
|
+
"value": "The source code for Bitcoin is published on GitHub. If you want to increase the number of tokens, here are some instructions:—"
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"type": "list",
|
|
258
|
+
"children": [
|
|
259
|
+
{
|
|
260
|
+
"type": "list-item",
|
|
261
|
+
"children": [
|
|
262
|
+
{
|
|
263
|
+
"type": "text",
|
|
264
|
+
"value": "Open a GitHub account (free)."
|
|
265
|
+
}
|
|
266
|
+
]
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
"type": "list-item",
|
|
270
|
+
"children": [
|
|
271
|
+
{
|
|
272
|
+
"type": "text",
|
|
273
|
+
"value": "Click the “clone me” button on the Bitcoin repository page to create your own copy of the Bitcoin source code. We'll call your new repository “BitcoinDither”."
|
|
274
|
+
}
|
|
275
|
+
]
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"type": "list-item",
|
|
279
|
+
"children": [
|
|
280
|
+
{
|
|
281
|
+
"type": "text",
|
|
282
|
+
"value": "Change the line(s) of code in your new BitcoinDither repository that specify the maximum number of tokens."
|
|
283
|
+
}
|
|
284
|
+
]
|
|
285
|
+
}
|
|
286
|
+
]
|
|
287
|
+
}
|
|
288
|
+
]
|
|
289
|
+
}
|
|
290
|
+
]
|
|
291
|
+
},
|
|
292
|
+
"references": []
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
"isQuestion": false,
|
|
296
|
+
"isAnswer": true,
|
|
297
|
+
"publishedDate": "2024-01-24T02:58:59.440Z",
|
|
298
|
+
"children": [],
|
|
299
|
+
"byline": "Jaren Kerr",
|
|
300
|
+
"author": {
|
|
301
|
+
"role": "FT Expert"
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
]
|
|
305
|
+
}
|
|
306
|
+
]
|
|
307
|
+
}
|