Skip to content

Commit 93a71a2

Browse files
authored
Merge pull request #338 from danmarshall/master
Chat width via props
2 parents 4a480b9 + c5d1bcc commit 93a71a2

8 files changed

Lines changed: 243 additions & 103 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ node_modules
66
/local
77
/.vscode
88
npm-debug.log
9+
debug.log
910
test/src
1011
npm
1112
cdn

.npmignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
/.gitignore
99
/.npmignore
1010
/.vscode
11+
debug.log

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@
3939
"@types/deep-freeze": "0.0.29",
4040
"@types/he": "^0.5.29",
4141
"@types/marked": "0.0.28",
42-
"@types/react": "^0.14.55",
43-
"@types/react-dom": "^0.14.20",
42+
"@types/react": "^15.0.14",
43+
"@types/react-dom": "^0.14.23",
4444
"chai": "^3.5.0",
4545
"chai-subset": "^1.4.0",
4646
"deep-freeze": "0.0.1",

src/ActivityView.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { FormatState } from './Store';
88
const Attachments = (props: {
99
attachments: Attachment[],
1010
attachmentLayout: AttachmentLayout,
11-
measureParentHorizontalOverflow?: () => number,
1211
format: FormatState,
1312
onCardAction: (type: string, value: string) => void,
1413
onImageLoad: () => void
@@ -36,7 +35,6 @@ const Attachments = (props: {
3635
export interface ActivityViewProps {
3736
format: FormatState,
3837
activity: Activity,
39-
measureParentHorizontalOverflow?: () => number,
4038
onCardAction: (type: string, value: string) => void,
4139
onImageLoad: () => void
4240
}
@@ -47,7 +45,8 @@ export class ActivityView extends React.Component<ActivityViewProps, {}> {
4745
}
4846

4947
shouldComponentUpdate(nextProps: ActivityViewProps) {
50-
return this.props.activity !== nextProps.activity || this.props.format !== nextProps.format;
48+
// if the activity changed, re-render
49+
return this.props.activity != nextProps.activity || this.props.format != nextProps.format;
5150
}
5251

5352
render() {

src/Carousel.tsx

Lines changed: 65 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@ import * as React from 'react';
22
import { Attachment } from 'botframework-directlinejs';
33
import { AttachmentView } from './Attachment';
44
import { FormatState } from './Store';
5+
import { konsole } from './Chat';
56

67
export interface CarouselProps {
78
format: FormatState,
8-
measureParentHorizontalOverflow?: () => number,
99
attachments: Attachment[],
1010
onCardAction: (type: string, value: string) => void,
11-
onImageLoad: ()=> void
11+
onImageLoad: () => void
1212
}
1313

1414
export interface CarouselState {
15+
contentWidth: number;
1516
previousButtonEnabled: boolean;
1617
nextButtonEnabled: boolean;
1718
}
@@ -23,14 +24,14 @@ export class Carousel extends React.Component<CarouselProps, CarouselState> {
2324
private scrollSyncTimer: number;
2425
private scrollDurationTimer: number;
2526
private animateDiv: HTMLDivElement;
26-
private resizeListener = () => this.resize();
27-
private scrollEventListener =() => this.onScroll();
27+
private scrollEventListener = () => this.onScroll();
2828
private scrollAllowInterrupt = true;
2929

3030
constructor(props: CarouselProps) {
3131
super(props);
3232

3333
this.state = {
34+
contentWidth: undefined,
3435
previousButtonEnabled: false,
3536
nextButtonEnabled: false
3637
};
@@ -50,33 +51,55 @@ export class Carousel extends React.Component<CarouselProps, CarouselState> {
5051
this.scrollAllowInterrupt = true;
5152
}
5253

53-
private manageScrollButtons() {
54-
const previousEnabled = this.scrollDiv.scrollLeft > 0;
55-
const max = this.scrollDiv.scrollWidth - this.scrollDiv.offsetWidth;
56-
const nextEnabled = this.scrollDiv.scrollLeft < max;
57-
58-
//TODO: both buttons may become disabled when the container is wide, and will not become re-enabled unless a resize event calls manageScrollButtons()
59-
const newState: CarouselState = {
60-
previousButtonEnabled: previousEnabled,
61-
nextButtonEnabled: nextEnabled
54+
private getScrollButtonState() {
55+
return {
56+
previousButtonEnabled: this.scrollDiv.scrollLeft > 0,
57+
nextButtonEnabled: this.scrollDiv.scrollLeft < this.scrollDiv.scrollWidth - this.scrollDiv.offsetWidth
6258
};
59+
}
6360

64-
this.setState(newState);
61+
private manageScrollButtons() {
62+
this.setState(this.getScrollButtonState());
6563
}
6664

67-
private componentDidMount() {
65+
componentDidMount() {
6866
this.manageScrollButtons();
6967

7068
this.scrollDiv.addEventListener('scroll', this.scrollEventListener);
7169

7270
this.scrollDiv.style.marginBottom = -(this.scrollDiv.offsetHeight - this.scrollDiv.clientHeight) + 'px';
71+
}
72+
73+
componentDidUpdate() {
74+
konsole.log('carousel componentDidUpdate');
75+
76+
if (this.props.format.carouselMargin != undefined) {
77+
//after the attachments have been rendered, we can now measure their actual width
78+
if (this.state.contentWidth == undefined) {
79+
this.root.style.width = '';
80+
this.setState({ contentWidth: this.root.offsetWidth });
81+
} else {
82+
//compare scroll state to desired scroll state
83+
var desiredButtonState = this.getScrollButtonState();
84+
if (desiredButtonState.nextButtonEnabled != this.state.nextButtonEnabled
85+
|| desiredButtonState.previousButtonEnabled != this.state.previousButtonEnabled) {
86+
this.setState(desiredButtonState);
87+
}
88+
}
89+
}
90+
}
91+
92+
componentWillReceiveProps(nextProps: CarouselProps) {
93+
konsole.log('carousel componentWillReceiveProps');
7394

74-
window.addEventListener('resize', this.resizeListener);
95+
if (this.props.format.chatWidth != nextProps.format.chatWidth) {
96+
//this will invalidate the saved measurement, in componentDidUpdate a new measurement will be triggered
97+
this.setState({ contentWidth: undefined });
98+
}
7599
}
76100

77-
private componentWillUnmount() {
101+
componentWillUnmount() {
78102
this.scrollDiv.removeEventListener('scroll', this.scrollEventListener);
79-
window.removeEventListener('resize', this.resizeListener);
80103
}
81104

82105
private onScroll() {
@@ -145,20 +168,29 @@ export class Carousel extends React.Component<CarouselProps, CarouselState> {
145168
}, 1);
146169
}
147170

171+
private getMaxMessageContentWidth() {
172+
if (this.props.format.chatWidth != undefined && this.props.format.carouselMargin != undefined)
173+
return this.props.format.chatWidth - this.props.format.carouselMargin;
174+
}
175+
148176
render() {
177+
let style: React.CSSProperties;
178+
const maxMessageContentWidth = this.getMaxMessageContentWidth();
179+
180+
if (maxMessageContentWidth && this.state.contentWidth > maxMessageContentWidth) {
181+
style = { width: maxMessageContentWidth }
182+
}
183+
149184
return (
150-
<div className="wc-carousel" ref={ div => this.root = div }>
151-
<button disabled={!this.state.previousButtonEnabled} className="scroll previous" onClick={ () => this.scrollBy(-1) }>
185+
<div className="wc-carousel" ref={ div => this.root = div } style={ style }>
186+
<button disabled={ !this.state.previousButtonEnabled } className="scroll previous" onClick={ () => this.scrollBy(-1) }>
152187
<svg>
153188
<path d="M 16.5 22 L 19 19.5 L 13.5 14 L 19 8.5 L 16.5 6 L 8.5 14 L 16.5 22 Z" />
154189
</svg>
155190
</button>
156191
<div className="wc-carousel-scroll-outer">
157192
<div className="wc-carousel-scroll" ref={ div => this.scrollDiv = div }>
158-
<CarouselAttachments
159-
{... this.props}
160-
onImageLoad = { () => this.resize() }
161-
/>
193+
<CarouselAttachments { ... this.props }/>
162194
</div>
163195
</div>
164196
<button disabled={ !this.state.nextButtonEnabled } className="scroll next" onClick={ () => this.scrollBy(1) }>
@@ -169,45 +201,29 @@ export class Carousel extends React.Component<CarouselProps, CarouselState> {
169201
</div >
170202
)
171203
}
172-
173-
resize() {
174-
175-
//remove the style width so that the actual content can be measured
176-
this.root.style.width = '';
177-
178-
if (this.props.measureParentHorizontalOverflow) {
179-
const overflow = this.props.measureParentHorizontalOverflow();
180-
if (overflow > 0) {
181-
this.root.style.width = (this.root.offsetWidth - overflow) + 'px';
182-
}
183-
}
184-
185-
this.manageScrollButtons();
186-
this.props.onImageLoad();
187-
}
188204
}
189205

190206
export interface CarouselAttachmentProps {
191207
format: FormatState
192208
attachments: Attachment[]
193209
onCardAction: (type: string, value: string) => void
194-
onImageLoad: ()=> void
210+
onImageLoad: () => void
195211
}
196212

197213
class CarouselAttachments extends React.Component<CarouselAttachmentProps, {}> {
198214

215+
shouldComponentUpdate(nextProps: CarouselAttachmentProps) {
216+
return this.props.attachments != this.props.attachments || this.props.format != nextProps.format;
217+
}
218+
199219
render() {
220+
const { attachments, ... props } = this.props;
200221
return (
201-
<ul>{this.props.attachments.map((attachment, index) =>
222+
<ul>{ this.props.attachments.map((attachment, index) =>
202223
<li key={ index } className="wc-carousel-item">
203-
<AttachmentView
204-
attachment={ attachment }
205-
format={ this.props.format }
206-
onCardAction={ this.props.onCardAction }
207-
onImageLoad={ () => this.props.onImageLoad() }
208-
/>
224+
<AttachmentView attachment={ attachment } { ... props }/>
209225
</li>
210-
)}</ul>
226+
) }</ul>
211227
);
212228
}
213229
}

src/Chat.tsx

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export interface ChatProps {
2727
locale?: string,
2828
selectedActivity?: BehaviorSubject<ActivityOrID>,
2929
sendTyping?: boolean,
30-
formatOptions?: FormatOptions
30+
formatOptions?: FormatOptions,
31+
resize?: 'none' | 'window' | 'detect'
3132
}
3233

3334
export class Chat extends React.Component<ChatProps, {}> {
@@ -40,6 +41,9 @@ export class Chat extends React.Component<ChatProps, {}> {
4041
private connectionStatusSubscription: Subscription;
4142
private selectedActivitySubscription: Subscription;
4243

44+
private chatviewPanel: HTMLElement;
45+
private resizeListener = () => this.setSize();
46+
4347
constructor(props: ChatProps) {
4448
super(props);
4549

@@ -72,15 +76,27 @@ export class Chat extends React.Component<ChatProps, {}> {
7276
}
7377
}
7478

79+
private setSize() {
80+
this.store.dispatch<FormatAction>({
81+
type: 'Set_Size',
82+
width: this.chatviewPanel.offsetWidth,
83+
height: this.chatviewPanel.offsetHeight
84+
});
85+
}
86+
7587
componentDidMount() {
76-
const props = this.props;
88+
// Now that we're mounted, we know our dimensions. Put them in the store (this will force a re-render)
89+
this.setSize();
7790

7891
const botConnection = this.props.directLine
7992
? (this.botConnection = new DirectLine(this.props.directLine))
8093
: this.props.botConnection
8194
;
8295

83-
this.store.dispatch<ConnectionAction>({ type: 'Start_Connection', user: props.user, bot: props.bot, botConnection, selectedActivity: props.selectedActivity });
96+
if (this.props.resize === 'window')
97+
window.addEventListener('resize', this.resizeListener);
98+
99+
this.store.dispatch<ConnectionAction>({ type: 'Start_Connection', user: this.props.user, bot: this.props.bot, botConnection, selectedActivity: this.props.selectedActivity });
84100

85101
this.connectionStatusSubscription = botConnection.connectionStatus$.subscribe(connectionStatus =>
86102
this.store.dispatch<ConnectionAction>({ type: 'Connection_Change', connectionStatus })
@@ -91,8 +107,8 @@ export class Chat extends React.Component<ChatProps, {}> {
91107
error => konsole.log("activity$ error", error)
92108
);
93109

94-
if (props.selectedActivity) {
95-
this.selectedActivitySubscription = props.selectedActivity.subscribe(activityOrID => {
110+
if (this.props.selectedActivity) {
111+
this.selectedActivitySubscription = this.props.selectedActivity.subscribe(activityOrID => {
96112
this.store.dispatch<HistoryAction>({
97113
type: 'Select_Activity',
98114
selectedActivity: activityOrID.activity || this.store.getState().history.activities.find(activity => activity.id === activityOrID.id)
@@ -108,23 +124,36 @@ export class Chat extends React.Component<ChatProps, {}> {
108124
this.selectedActivitySubscription.unsubscribe();
109125
if (this.botConnection)
110126
this.botConnection.end();
127+
window.removeEventListener('resize', this.resizeListener);
111128
}
112129

130+
// At startup we do three render passes:
131+
// 1. To determine the dimensions of the chat panel (nothing needs to actually render here, so we don't)
132+
// 2. To determine the margins of any given carousel (we just render one mock activity so that we can measure it)
133+
// 3. (this is also the normal re-render case) To render without the mock activity
134+
113135
render() {
114136
const state = this.store.getState();
115137
konsole.log("BotChat.Chat state", state);
116-
let header;
138+
139+
// only render real stuff after we know our dimensions
140+
let header: JSX.Element;
117141
if (state.format.options.showHeader) header =
118142
<div className="wc-header">
119143
<span>{ state.format.strings.title }</span>
120144
</div>;
121145

146+
let resize: JSX.Element;
147+
if (this.props.resize === 'detect') resize =
148+
<ResizeDetector onresize={ this.resizeListener } />;
149+
122150
return (
123151
<Provider store={ this.store }>
124-
<div className={ "wc-chatview-panel" }>
152+
<div className="wc-chatview-panel" ref={ div => this.chatviewPanel = div }>
125153
{ header }
126154
<History />
127155
<Shell />
156+
{ resize }
128157
</div>
129158
</Provider>
130159
);
@@ -189,4 +218,14 @@ export const konsole = {
189218
if (typeof(window) !== 'undefined' && window["botchatDebug"] && message)
190219
console.log(message, ... optionalParams);
191220
}
192-
}
221+
}
222+
223+
// note: container of this element must have CSS position of either absolute or relative
224+
const ResizeDetector = (props: {
225+
onresize: () => void
226+
}) =>
227+
// adapted to React from https://github.com/developit/simple-element-resize-detector
228+
<iframe
229+
style={ { position: 'absolute', left: '0', top: '-100%', width: '100%', height: '100%', margin: '1px 0 0', border: 'none', opacity: 0, visibility: 'hidden', pointerEvents: 'none' } }
230+
ref={ frame => frame.contentWindow.onresize = props.onresize }
231+
/>;

0 commit comments

Comments
 (0)