From a2cd338d689147c795e8b6705b22556b38e53ac8 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 13 Apr 2024 00:16:27 -0400 Subject: [PATCH] join room, check-in, check-out, socket disconnect enforced, I wrote code. --- app/controllers/chat.js | 17 ++ app/models/chat-room.js | 7 +- app/services/chat.js | 139 +++++++++++++- .../member-list-item-standalone.pug | 2 + .../chat/components/member-list-item.pug | 99 ++++++++++ app/views/chat/room/view.pug | 92 +++++----- client/css/dtp-site.less | 1 + client/css/site/button.less | 23 +++ client/css/site/stage.less | 82 +++++---- client/img/default-poster.png | Bin 0 -> 38215 bytes client/js/chat-client.js | 171 +++++++++++++++++- lib/client/js/dtp-log.js | 2 +- lib/client/js/dtp-socket.js | 14 +- lib/site-ioserver.js | 7 +- webpack.config.js | 4 +- 15 files changed, 566 insertions(+), 94 deletions(-) create mode 100644 app/views/chat/components/member-list-item-standalone.pug create mode 100644 app/views/chat/components/member-list-item.pug create mode 100644 client/img/default-poster.png diff --git a/app/controllers/chat.js b/app/controllers/chat.js index c39ff86..6b9e4cc 100644 --- a/app/controllers/chat.js +++ b/app/controllers/chat.js @@ -50,6 +50,12 @@ export default class ChatController extends SiteController { this.getRoomCreateView.bind(this), ); + router.get( + '/room/:roomId/join', + // limiterService.create(limiterService.config.chat.getRoomView), + this.getRoomJoin.bind(this), + ); + router.get( '/room/:roomId', // limiterService.create(limiterService.config.chat.getRoomView), @@ -86,6 +92,17 @@ export default class ChatController extends SiteController { res.render('chat/room/create'); } + async getRoomJoin (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + await chatService.joinRoom(res.locals.room, req.user); + res.status(200).json({ success: true, room: res.locals.room }); + } catch (error) { + this.log.error('failed to join chat room', { error }); + return next(error); + } + } + async getRoomView (req, res) { res.locals.currentView = 'chat-room'; res.render('chat/room/view'); diff --git a/app/models/chat-room.js b/app/models/chat-room.js index 475f1ea..88b8fcf 100644 --- a/app/models/chat-room.js +++ b/app/models/chat-room.js @@ -10,7 +10,7 @@ import mongoose from 'mongoose'; const Schema = mongoose.Schema; const ChatRoomSchema = new Schema({ - created: { type: Date, default: Date.now, required: true, index: 1, expires: '7d' }, + created: { type: Date, default: Date.now, required: true, index: 1 }, lastActivity: { type: Date, index: -1 }, owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, name: { type: String, required: true }, @@ -18,7 +18,12 @@ const ChatRoomSchema = new Schema({ capacity: { type: Number, required: true, min: MIN_ROOM_CAPACITY, max: MAX_ROOM_CAPACITY }, invites: { type: [Schema.ObjectId], select: false }, members: { type: [Schema.ObjectId], select: false }, + present: { type: [Schema.ObjectId], select: false }, banned: { type: [Schema.ObjectId], select: false }, + stats: { + memberCount: { type: Number, default: 0, min: 0, max: MAX_ROOM_CAPACITY, required: true }, + presentCount: { type: Number, default: 0, min: 0, max: MAX_ROOM_CAPACITY, required: true }, + }, }); export default mongoose.model('ChatRoom', ChatRoomSchema); \ No newline at end of file diff --git a/app/services/chat.js b/app/services/chat.js index 18b1077..1d47152 100644 --- a/app/services/chat.js +++ b/app/services/chat.js @@ -10,6 +10,8 @@ const ChatRoom = mongoose.model('ChatRoom'); const ChatMessage = mongoose.model('ChatMessage'); // const ChatRoomInvite = mongoose.model('ChatRoomInvite'); +import numeral from 'numeral'; + import { SiteService, SiteError } from '../../lib/site-lib.js'; import { MAX_ROOM_CAPACITY } from '../models/lib/constants.js'; @@ -24,6 +26,11 @@ export default class ChatService extends SiteService { async start ( ) { const { user: userService } = this.dtp.services; + + this.templates = { + memberListItem: this.loadViewTemplate('chat/components/member-list-item-standalone.pug'), + }; + this.populateChatRoom = [ { path: 'owner', @@ -45,6 +52,7 @@ export default class ChatService extends SiteService { } room.capacity = MAX_ROOM_CAPACITY; room.members = [owner._id]; + room.stats.memberCount = 1; await room.save(); @@ -60,12 +68,141 @@ export default class ChatService extends SiteService { } async joinRoom (room, user) { - await ChatRoom.updateOne( + const roomData = await ChatRoom.findOne({ _id: room._id, banned: user._id }).lean(); + if (roomData) { + throw new SiteError(401, 'You are banned from this chat room'); + } + + const response = await ChatRoom.updateOne( { _id: room._id }, { $push: { members: user._id }, }, ); + + this.log.debug('joinRoom complete', { response }); + return response; + } + + async chatRoomCheckIn (room, member) { + const NOW = new Date(); + + // indicate presence in the chat room's Mongo document + const roomData = await ChatRoom.findOneAndUpdate( + { _id: room._id }, + { + $addToSet: { present: member._id }, + $inc: { 'stats.presentCount': 1 }, + }, + { + new: true, + }, + ); + + this.log.debug('member checking into chat room', { + room: { + _id: room._id, + name: room.name, + presentCount: roomData.stats.presentCount, + }, + member: { + _id: member._id, + username: member.username, + }, + }); + + /* + * Broadcast a control message to all room members that a new member has + * joined the room. + */ + const displayList = this.createDisplayList('chat-control'); + displayList.removeElement(`ul#present-members li[data-member-id="${member._id}"]`); + displayList.addElement( + `ul#chat-active-members[data-room-id="${room._id}"]`, + 'afterBegin', + this.templates.memberListItem({ room, member }), + ); + + displayList.setTextContent( + `.chat-present-count`, + numeral(roomData.stats.presentCount).format('0,0'), + ); + + const systemMessage = { + created: NOW.toISOString(), + content: `@${member.username} has connected to the room.`, + }; + + this.dtp.emitter.to(room._id.toString()).emit('chat-control', { displayList, systemMessages: [systemMessage] }); + + } + + async chatRoomCheckOut (room, member) { + const NOW = new Date(); + const roomData = await ChatRoom.findOneAndUpdate( + { _id: room._id }, + { + $pull: { present: member._id }, + $inc: { 'stats.presentCount': -1 }, + }, + { + new: true, + }, + ); + + this.log.debug('member checking out of chat room', { + room: { + _id: room._id, + name: room.name, + presentCount: roomData.stats.presentCount, + }, + member: { + _id: member._id, + username: member.username, + }, + }); + + /* + * Broadcast a control message to all room members that a new member has + * joined the room. + */ + const displayList = this.createDisplayList('chat-control'); + displayList.removeElement(`ul#chat-active-members li[data-member-id="${member._id}"]`); + + displayList.setTextContent( + `.chat-present-count`, + numeral(roomData.stats.presentCount).format('0,0'), + ); + + const systemMessage = { + created: NOW.toISOString(), + content: `@${member.username} has connected to the room.`, + }; + + this.dtp.emitter.to(room._id.toString()).emit('chat-control', { displayList, systemMessages: [systemMessage] }); + } + + async checkRoomMember (room, member) { + if (room.owner._id.equals(member._id)) { + return true; + } + + const search = { _id: room._id, members: member._id }; + const checkRoom = await ChatRoom.findOne(search).select('name').lean(); + if (!checkRoom) { + throw new SiteError(403, `You are not a member of ${checkRoom.name}`); + } + + return true; + } + + async isRoomMember (room, member) { + if (room.owner._id.equals(member._id)) { + return true; + } + const search = { _id: room._id, members: member._id }; + const checkRoom = await ChatRoom.findOne(search).select('name').lean(); + return !!checkRoom; } async leaveRoom (room, user) { diff --git a/app/views/chat/components/member-list-item-standalone.pug b/app/views/chat/components/member-list-item-standalone.pug new file mode 100644 index 0000000..5562967 --- /dev/null +++ b/app/views/chat/components/member-list-item-standalone.pug @@ -0,0 +1,2 @@ +include member-list-item ++renderChatMemberListItem (room, member, { isHost, isGuest }) \ No newline at end of file diff --git a/app/views/chat/components/member-list-item.pug b/app/views/chat/components/member-list-item.pug new file mode 100644 index 0000000..6f353a6 --- /dev/null +++ b/app/views/chat/components/member-list-item.pug @@ -0,0 +1,99 @@ +include ../../user/components/profile-picture +mixin renderChatMemberListItem (room, member, options) + - + options = Object.assign({ + isHost: false, + isGuest: false, + }, options); + + var isRoomOwner = room.owner._id.equals(member._id); + var isSystemMod = user && user.flags && (user.flags.isAdmin || user.flags.isModerator); + var memberName = member.displayName || member.username; + // var isChannelMod = user && Array.isArray(message.channel.moderators) && !!message.channel.moderators.find((moderator) => moderator._id.equals(user._id)); + + li(data-member-id= member._id, data-member-username= member.username) + div(uk-grid).uk-grid-collapse.uk-flex-middle.member-list-item + .uk-width-auto + img(src='/img/default-member.png').member-profile-icon + .uk-width-expand.uk-text-small + a(href=`/member/${member.username}`, uk-tooltip={ title: `Visit ${member.username}`}).uk-link-reset + .member-display-name= member.displayName || member.username + .member-username @#{member.username} + .uk-width-auto.chat-user-menu + button(type="button").dtp-button-dropdown + i.fas.fa-ellipsis-v + div( + data-room-id= room._id, + uk-dropdown={ animation: "uk-animation-scale-up", duration: 250 }, + data-mode="click", + ).dtp-chatmsg-menu + ul.uk-nav.uk-dropdown-nav + li.uk-nav-header= member.username + li + a( + href="", + data-username= member.username, + onclick="return dtp.app.mentionChatUser(event);", + ) Mention + li + a( + href="", + data-room-id= room._id, + data-room-name= room.name, + data-user-id= member._id, + data-username= member.username, + onclick="return dtp.app.muteChatUser(event);", + ) Mute + + if (options.isHost || options.isGuest) && !isRoomOwner + li.uk-nav-divider + if options.isHost + li + a( + href, + data-environment="ChatRoom", + data-room-id= room._id, + data-room-name= room.name, + data-user-id= member._id, + data-username= member.username, + data-display-name= member.displayName, + onclick="return dtp.app.removeRoomHost(event);", + ) Remove host + if options.isGuest + li + a( + href, + data-environment="ChatRoom", + data-room-id= room._id, + data-room-name= room.name, + data-user-id= member._id, + data-username= member.username, + data-display-name= member.displayName, + onclick="return dtp.app.removeRoomGuest(event);", + ) Remove guest + + if isSystemMod || isChannelMod + li.uk-nav-divider + li + a( + href="", + data-environment="ChatRoom", + data-room-id= room._id, + data-room-name= room.name, + data-user-id= member._id, + data-username= member.username, + data-display-name= member.displayName, + onclick="return dtp.app.confirmBanUserFromEnvironment(event);", + ) Ban from room + + if isSystemMod + li + a( + href="", + data-room-id= room._id, + data-room-name= room.name, + data-user-id= member._id, + data-username= member.username, + data-display-name= member.displayName, + onclick="return dtp.adminApp.confirmBanUser(event);", + ) Ban from #{site.name} \ No newline at end of file diff --git a/app/views/chat/room/view.pug b/app/views/chat/room/view.pug index a853031..68fb840 100644 --- a/app/views/chat/room/view.pug +++ b/app/views/chat/room/view.pug @@ -27,7 +27,7 @@ block view-content mixin renderLiveMember (member) div(data-user-id= member._id, data-username= member.username).stage-live-member - video(src="/static/video/gdl-crush.mp4", autoplay, muted, loop, disablepictureinpicture, disableremoteplayback) + video(poster="/img/default-poster.png", disablepictureinpicture, disableremoteplayback) .uk-flex.live-meta.no-select .live-username.uk-width-expand .uk-text-truncate= member.displayName || member.username @@ -38,30 +38,20 @@ block view-content i.fas.fa-cog .dtp-chat-stage - .chat-sidebar - .chat-stage-header Active Members + #room-member-panel.chat-sidebar + .chat-stage-header + div(uk-grid).uk-grid-small.uk-grid-middle + .uk-width-expand + .uk-text-truncate Active Members + .uk-width-auto + .chat-present-count.uk-text-small --- .sidebar-panel - ul(id="chat-active-members").uk-list.uk-list-collapse - +renderMemberListEntry(user) - +renderMemberListEntry(user) - +renderMemberListEntry(user) - +renderMemberListEntry(user) - +renderMemberListEntry(user) - +renderMemberListEntry(user) - +renderMemberListEntry(user) - +renderMemberListEntry(user) - +renderMemberListEntry(user) - +renderMemberListEntry(user) - +renderMemberListEntry(user) - +renderMemberListEntry(user) + ul(id="chat-active-members", data-room-id= room._id).uk-list.uk-list-collapse .chat-stage-header Idle Members .sidebar-panel - ul(id="chat-idle-members").uk-list.uk-list-collapse - +renderMemberListEntry(user, { idle: true }) - +renderMemberListEntry(user, { idle: true }) - +renderMemberListEntry(user, { idle: true }) - +renderMemberListEntry(user, { idle: true }) + .uk-text-italic.uk-text-muted There are no idle members. + ul(id="chat-idle-members", data-room-id= room._id, hidden).uk-list.uk-list-collapse .chat-container .chat-stage-header @@ -69,32 +59,40 @@ block view-content .uk-width-expand= room.name .chat-content-panel - div(uk-grid).uk-grid-collapse.live-content - .uk-width-expand - .chat-media - div(uk-grid) - div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@l uk-width-1-4@xl") - +renderLiveMember(user) + .live-content + .chat-media + div(uk-grid) + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@l uk-width-1-4@xl") + +renderLiveMember(user) - .uk-width-auto - .chat-messages - - - var testMessage = { - created: new Date(), - author: user, - content: "This is the chat content panel. It should word-wrap and scroll correctly, and will be where individual chat messages will render as they arrive and are sent.", - }; + div(id="chat-message-list").chat-messages + - + var testMessage = { + created: new Date(), + author: user, + content: "This is the chat content panel. It should word-wrap and scroll correctly, and will be where individual chat messages will render as they arrive and are sent.", + }; + + +renderChatMessage(testMessage) + +renderChatMessage(testMessage) + +renderChatMessage(testMessage) - +renderChatMessage(testMessage) - +renderChatMessage(testMessage) - +renderChatMessage(testMessage) - +renderChatMessage(testMessage) + .chat-input-panel + form( + id="chat-input-form", + data-room-id= room._id, + onsubmit="return window.dtp.app.sendUserChat(event);", + hidden= user && user.flags && user.flags.isCloaked, + ).uk-form + textarea(id="chat-input-text", name="chatInput", rows=2).uk-textarea.uk-resize-none.uk-border-rounded + .uk-margin-small + .uk-flex + .uk-width-expand + .uk-width-auto + button(id="chat-send-btn", type="submit", uk-tooltip={ title: 'Send message' }).uk-button.uk-button-default.uk-button-small.uk-border-rounded + i.fas.fa-paper-plane - .chat-input-panel - textarea(id="chat-input", name="chatInput", rows=2).uk-textarea.uk-resize-none.uk-border-rounded - .uk-margin-small - .uk-flex - .uk-width-expand - .uk-width-auto - button(type="submit", uk-tooltip={ title: 'Send message' }).uk-button.uk-button-default.uk-button-small.uk-border-rounded - i.fas.fa-paper-plane \ No newline at end of file +block viewjs + script. + window.dtp = window.dtp || { }; + window.dtp.room = !{JSON.stringify(room)}; \ No newline at end of file diff --git a/client/css/dtp-site.less b/client/css/dtp-site.less index 780ef8c..f70619b 100644 --- a/client/css/dtp-site.less +++ b/client/css/dtp-site.less @@ -1,4 +1,5 @@ @import "site/main.less"; +@import "site/button.less"; @import "site/navbar.less"; @import "site/stage.less"; @import "site/image.less"; \ No newline at end of file diff --git a/client/css/site/button.less b/client/css/site/button.less index e69de29..13fad7a 100644 --- a/client/css/site/button.less +++ b/client/css/site/button.less @@ -0,0 +1,23 @@ +button.dtp-button-dropdown { + /* + * This is a series of settings that improve the "clickable area" of the + * button without making it take up more space in the layout. + */ + display: inline-block; + position: relative; + padding: 1em; + margin: -1em; + + &.always-on-top { + z-index: 1; + } + + /* + * The actual "style" of the thing + */ + background: none; + border: none; + outline: none; + color: #e8e8e8; + cursor: pointer; +} \ No newline at end of file diff --git a/client/css/site/stage.less b/client/css/site/stage.less index d276163..db6afed 100644 --- a/client/css/site/stage.less +++ b/client/css/site/stage.less @@ -7,11 +7,6 @@ display: flex; - .flex-row-break { - flex-basis: 100%; - height: 0; - } - .chat-stage-header { flex-grow: 0; flex-shrink: 0; @@ -42,37 +37,52 @@ } } + img.member-profile-icon { + width: 32px; + height: auto; + border-radius: 5px; + margin-right: 5px; + } + + .member-list-item { + line-height: 1.1em; + + .member-display-name { + line-height: 1.1em; + } + .member-username { + line-height: 1em; + } + } + .chat-container { box-sizing: border-box; display: flex; + flex-grow: 1; flex-direction: column; - align-items: stretch; background-color: #a8a8a8; color: #1a1a1a; .chat-content-panel { box-sizing: border-box; - position: relative; display: flex; - flex-wrap: wrap; - - height: 100%; + flex: 1; .live-content { - flex-grow: 1; + display: flex; + flex: 1; .chat-media { box-sizing: border-box; - flex-basis: 0; flex-grow: 1; - height: 100%; padding: @stage-panel-padding; background-color: #4a4a4a; + overflow-y: auto; .stage-live-member { padding: 4px 4px 0 4px; @@ -84,6 +94,11 @@ video { display: block; + + aspect-ratio: 16 / 9; + width: 100%; + height: auto; + border-radius: 3px; } @@ -104,26 +119,23 @@ flex-shrink: 0; width: 320px; - height: 100%; padding: @stage-panel-padding; overflow-y: scroll; .chat-message { - line-height: 1; - + padding: 5px; margin-bottom: 10px; - border-bottom: solid 1px #4a4a4a; - - &:last-child { - border-bottom: none; - } + + line-height: 1; + border-radius: 4px; + + background-color: #e8e8e8; + color: #1a1a1a; - img.member-profile-icon { - width: 32px; - height: auto; - border-radius: 5px; - margin-right: 5px; + &.system-message { + background-color: #d1f3db; + border-radius: 4px; } .message-attribution { @@ -154,15 +166,15 @@ } } - .chat-input-panel { - flex-basis: 100%; - flex-grow: 0; - flex-shrink: 0; - - padding: @stage-panel-padding; - background-color: #1a1a1a; - color: #e8e8e8; - } + } + + .chat-input-panel { + flex-grow: 0; + flex-shrink: 0; + + padding: @stage-panel-padding; + background-color: #1a1a1a; + color: #e8e8e8; } } diff --git a/client/img/default-poster.png b/client/img/default-poster.png new file mode 100644 index 0000000000000000000000000000000000000000..f7dc2cf9146a0081bab7e091f5974788a5a79e3a GIT binary patch literal 38215 zcmeFXWmH>T)GZvKK!IY#owjHxUffH826xxu?(VceDeeS!m!iR~P+W?8aEIW*xuMVd z{`>9HzeIsw;nVB$ z1_1D^yi_$^l#Se}9h~gVENx7wT|6C3sZBjB%>V$8g^E<`q<1_~!B5TvwJ+MmSVCz! zoxMCs?tfHaA_PIsotR8LHw6aMDC~9C5z=Gv)ZKm_RBe0Gb65V zw4nAsFCTnG=>MGgUh-6hx_tE;<6ymcll6Oj7ZOiW<@|XNGCQ`z&a7`Ql3}p`i+ot* znSp)w^kF^medr68zSX7PhkZWq&W-EPsLXm=fiS`NHg}kPM0OSxAbl{_>nv>=*i5|Y z;|#gP?dDB?HM*;c!LE?!?asnu`VQ=g!jC*vOQj)B+Q+}oJhAs_y2j(8Bg|O1s&eAG zs<&)ong>Pu&uh(_`3zsX~ROkX1$zgBkt)&~J%mpe)& zzuC6Op$%`L`^U(sN7ohu1%K_>DQNAz=ff<4<*hBjpQ@+hX~!yGH?WoY&_hcJm5%IM z{n@@7PHVKQU$B4V&Y2&5$2UhlLs?Ml2o%U^s_mm9m#W_UX5QOr!YGnUhmg`)xT$p( zr7h)V{N!`ljU0~*NMZ!6EZZwBGMz+2Ac$v+bDuC4na)>RnU2R8-DEnxmbFR} zMOh2_g$RWC2}9{ibz7=aR4v2~OX{Xn64GgD(fKUKr5)K^JSPLEwABeg){Ge|Y%g^A zPe_2!xuvrmSIs#2x{2`dG%doB()vKE5~NoZ>e{vymvpvu^XjZ^D0JoSd-7nJpQ@G< z5+?l#T!Y1Vk-{&OGmS?CF>IFTHhBBeotZ0}c+dBZ9Pif?Gu-E0!OP&o>K%R5>vXS$ zJ*iRN^A98134Ak^x#C4EGAIe<+88r(Hgk2ZT?@?M6}P)Yf#cSL zuwn$K`Ry%NW!%a5VzfOPLC&&{bWdg;Mn>D(k*!58j|^PoW+(T~_%~H{xgvx2HhjsL zqPN~RcSqCv!`N+(ROe>Qs!Yjbjz*?ZTE(5mbw;WQ$ndKp>bLmuUcKk$!LTYFF=$$~ zP}}8&b)}Xh6jt!)FDA(Ilo9FGS9htHeb|xB`vCkYXBe}fx;mw7%Xsf`=1wI-j|eE5 zYKac2)8Exw;CSQkjY+a>Dz^Pps#aH%qAxH7*7kq(bERF2{_zgbs=63vdy)+U zk5YXflHR@9(J}uCzH%{_CPJU%vq$(tJP2|n$LaFyD{3g>Kq;aw;T&=hnNe>M|I{Hv z99iI6ZHUZggFliHM)EuHGQzfQF8J$Ju9Rjj8x;dsK`kRGUcK6Z5)s$kimR^Rw-Zbd zs1YmQ60wYykuOkN8;UZ#d$OR$s5fKlwKjlnLNv~Yji>Kk8He#Ra1e3otqkwE?r$tq zsqHMvo05?vAeZSr{hyuViJ(l!ir?p??q<@>nJp*X?#9*4>k^=3lvT2joW<|?0zZ=f z7ze;P%bzO)}ChPnP58EJaW)y#kRau<2>2_hJhWe|h3ZpLSw zOD67Ipf`YA!Wq?j6#k{ee3_qRSqmgY@YA4EWv(sFYx%^HfQ9I{!Z6+>uLkkwA3GzX z>qTlp(wsKk^6DCd=3JH7W{7gJ$QkDQb39>y!fagCr}SDZ9FPlpH%bozQ?sk>NnRm= zVzv}2R?Z&p(@QpGHx60wNmC*|Mu6mp86=84 z%=16(1$xzJ@li>$D)G441N1S4y6BaLujJ7jjMHQTjk>UGe(%VU4YTKYdwfFWH*tME zN&8V^^H8Jvbx4U5u4FwY8k&5rS_D#an`%F%JpM~hm7iaCQ!0lP^@x^Uoe}_pKr$33>xj7tO<-%!G7tK9+X@$4qduYjxv&P4(P#=46IqS4vh4+f}+;)Ah={quLda zN;%$3>9iAr=BXiJ+{&}xl2<1%bZT4*(KIcVl&BU2xRb>BC$aZPW~pDU7vfBMY3Z98 zm}PfGCQXyQk&B^uiCFgiSm_(dv1JtDj7SNBy_!<%R>*?ko4Qy2TVv|RA5uHU=H3*H zjCoM2CWKF}C_0FFZv-Ek)!W$)gG{4I90enR8_O=VLmx1)9*qYc!?jrzX}dpRlis<+ zzo0%3s2w{QJ$X?e(2je%8xp+K^$H@s-k%(QoxlNEf7j2e3WLy+Zv?FtROTqkf(th(>ZF(h{n;yynZRj*;cj zwkuBJcK%RcCd%YE9rF-qRA_A4|I*YQc4)$BrT@%cQwb&9@0;UMD)_J9H;g>~a>@-L;0WzkT5(uIzgmDTbRt zje;VaV-~e&R?YWrL_5nETtdWt#Kc4O7H`tT-@8XP1$I!dNypH(6X-`@#Cg?_m-{&Z zkui5DHJgLB%sg*>lB8T&Pd-089HUi(oW9K9nxHMV5Yh<6Kfy|5$8-%LWl3n$e>sOU`hzWlyL}>V2uM$%vfkIw<;R8` zcUAN-#|VU~gw%wA8v0#CaI1~Udk_&{FA=S~sVtsh-p==`a2}fNDU}J_7|_lA%h0KW zrKP``47jT~86YMjbrShkY`m!iUreHL>5a1mX(KeD-8gi~ji&vv^8E+~z}{U&-AwA2 zt`C`fughOAqpPTF4=j(HU{c&g;gfKOb^S(J{)>p(7Ud_ zE%4*jJireZymB32F3^_oX5?2WvL+#I)^%p{P(dEdd9amJ5(&v$wD@Q^9Q9%~aIvS^ zYjsx>s@$V@sh!he?Ia|aMC4}pi#0%VKojZwS59FN+i+MB$C$Pb13R$Ve(K#f+ZSki z=*P4S5_**D=nhsC- zu@{=QFTbGHvA-CK#LlGSWD)PBm&QX05U66&zRtmHv6K*KBaNqSero@CO45!y_< z_2N64K5sB{#zOEmm`7>@_owZ!OedCT$0n0uXfLGPPOrgNk6TkO0!Tu5jjD`9if2p+=JKyU1K7lqR+dR_O7BgMtonw>Mc(2 zu262*K%Uw;q5G$oISjYgqp$MRa0Hz{?hD#rYp9QNhzZ*_0v`O$wK+&Hdw?|v{q9v@ z0*ThnRnN*iK}vIa3@yBniN9Mgu3d`2X#Px`NMA&3=|Pv+1oAn}7h1U1gI=R<39~HCH!VuLJB0kCbnyWYIdTytTu9PUpaaR_H+7cr7E@9fnD9 z9cSDEiIT7mQ-SW+<>n){miZ5B#GOW$@u$bb9&H4YE#=eiMs||L>-LRjQabz$%c&rnVs~QGw4R&kUIx0(ducC%W>^cddIrdjM>k7RX9Cg zbRAg~3rH>ZIdc!in|b&!Bipv2Ppb>&p1&-qz++_-<9&){t2QysHB(#Vc})lc9uzSY zc%Z*u(~+YsOZhfllVITU}) zEqmX%#)pRKe-aTQ2Bk@$V^n`dc35#bP4}gsQdg#5lam zbEJCK!tXHMPIRVxW4{L+6o21B3^jzES|IbR@_dZ+EJ=v4{#LsOp62J}g?bJj(-=i`d?sZzXVWmrb#_0}P^z8E~J`Mi&N$y%Ki% z7TO!o<+fRW0J%=sf21*juAlvi{C;-q68__t$4_LiqD#wSp?}~fw^p(m!Xmv~=K$$9 z9Z`|sSGc%{KO!vRR-LeCE+nNZ2_Hg!Yy<^{Xucyvp)fN^_Vzhbx}Lyu7RUOeyzG;$ zNj!p~Uxp;jTjx^lVEws^n;g-*Wr%u)qlRM0HgGlkHi3&w7=_a^L;mUt z-3NanzOetcYhgpu4w-yO>d>Z)P)y13n=y~-m|u6oQFUuu7YnxwidysSoe)mA@Z%5B zRDlH@lqHlhLU$Yn6)zrY7WH0Y9Bv*n?WfdhZJ zDl3p(osGak09+kOvl8aq=xh8I_-CC*#p#D=c#m~Zab4t<;Tzuo9944BipMsHsH;&R z9xmR=t4E8;2uk6Wos=U)PoaIjnIB~!W@Z8(l>E=TX}nT&d$&9#F&!Ty))ifvdzZ{y zzpyF1u%y`J>gGK(2;e7u@g5A}JVJMJD9*Tn>G<1z7;wA;Yjb@=?jZOe3i-7d!SmSf zJEs}XS^b)d`*o=i>V1P~mlHQzUS(PTftVsGGmXgIQ+;^{^04}$I&mY-J=#lA9uh7^kTT@ z^1ob&`rqCQidu@=`PFVCe#8{mSQA}9FkLXhTc=Jkzddrf>^-wM9%H zYPkcKXE_CD?O%>7HYvPFPJMVX+c8}J)82QixqUIz`Y=zEq_1$ zgdiu!5FL)#DhL1|e6ti6R|JZS|F7x>*V@v3V}-u_7A5aBn3R^#CO@QJkICWJ!Y9}( zR%7@StjJWiZ|Jl~^wIK*wk|pHY|uBVa5YpSb3F=OWU&*Zp7}1|UkT}Z17!=`jV?!# zm1XaxHl*)n!ItWVh~pQjAbGkT8#}dE?Qa9v0vvlx%}AW%5`uQFn?s|k+}Sg?e2>R) z)z1l$eBPdZ;2b7BML8Wd8(D2tAUC4bq7|m6i%UN8yC` zvZyzwB+5p5r1F+p@o!;hC993JtA6EqkZ8K}0fOm1W#&?h00I z3zi2waXJ*Z-%r!wJkhlM{hKQ#`o!(vxcoOkXw?ao*oVVJ#f?TpE=LitGPaxM)Y-{A z4&J_+S?SwDipi-|=IQKu!={~o=o;+~bAw$$L2mvcSy@g^V}uO}a4i(&tBllV_&5A> zBi`Z<|3Y*4qU8(#u)KTzL+~gNaf4q(bpgsrpzglF1aPt^q%&y3FOj%NXu62o+uEAi zxd6nSOpRPjO{m>1T`Z`jfO3i&fmlQU05t&kSxnVq;c(eAoz~SC`UC@)j?VZt7Znwm z8>NuMsBOIdl=~%ujC2D^*Re#FC(@=7q1zkq5-~SK4fsMVk^$Xf<{;_EU*f9bA7noc z1rX7Pvp*o!UisaYt3k$~(UY9R6T>dB^y5m-ol74<{tysnQOAyoYw5lo#NM~#HGG=? z@AH3a0a0l_Y521-!bO>0`vMx}nvP-4EVUy3qp`8ERsQ=bSzaPK03iFD&l-lo zFL3*1#s0xTJPm06(f7@M`mpRM9~UX6p8vz8*9IA+|B?}a$OZs(KH3Z=G0#9CkW<$m z8=C%4F#nFr*7H%FL4AY(z$*aYS5}F7S<-+BLl0K_{TAzi9@L9ywiKZKKK})LKHnjA zeEY?DQx+ff9VX>?e8a5(76iwo2mru`7y)SOWR&pjF5}a2bsy#ALcKNYZYKaf^&FzM zU5V6vI?;M74kdxOjRkg<5!WRS(6?kFj6;OoL*|I|1xE4a@R<_t+G-+&X?6w|8Pbo+6d`|#i z=?dYx3ze#_#B6NkbjO(LZf-;MAM{^#+EN6FTvbO4wSxiVeeCtm_5&a~c%vEun@_qi zplz2kI!6QaSw5pUt(KE?`)JRoZ7$I~^m)ut)^sw}ZCp=}H>t=vsep z=Uf+xILDIxJcl`|9>>;6fAdCLz_@e(=556uq6yTr#?EDWofbC8+ z`t)D>Vf2}E?T<&>BWXuL7%Yu`h0ycFDxco>iGx?LCis%f>-~-!>OISV~pu^Ed!y;sBX~iG)CBx}4L06HtIU!l5X4;G5}pyXR|% z@!y?{vkNKUAGMKU9`GNOTJ4Q&O-xL{IJ#A{M-)&GDVPgnBi!JAJptjm{q7iU6nHKu zSzdVESDoR$;yH`+zt4>s9T_}s8Q2^x)Hm2I^WTj<-H&Ot^_})nWj*w=`WYz*o_-fP z=|pl{3np3-NP{)qpA8-5gi_Y@QTZ=bwj4KW)dvG!8M6FKIltHtc$0^Oyp^`ogQ$0} zt!pLojKqGOf8;VE=C?x6CP;aK@w~wQAE3ZdZ1{W&9JK$w{+lO&`q{+)pBGC`$B})_ zW*y{_0n}UMv;csim(2rx^N>Q7Nbl@H-*x_9W5;x^ERzh3=84OSI9!-=ayy z_@FmY+{Ra;fnt&ri+}t;zW;h)oh^;(=g;uR>XSSF3&))m)uLSL@{tdKv3!;({Ab?> zkd#8s*JNG4nsg4d&GJ|gT{pIm6^*cID5kLL=F&91KipqP5ZQ;4sW#U}Mnq^TD2$EX z4Se+GhnYK)r#aiHyS@P+&5>nlAOZ|g69qGC|K@5SGY$3?!PX#e|-JPLou=AjdFsE`20zsRgmZ@!!o zDOZTlkDn@|I*m--)Sh$POy5k8lT>S07Xov0U$mWNn72lix>tHb+uVzuxH&U?&IZ~t zbmOxg4$aNX1eWnV_DhQ9v;fb(vIq`aRB(;^=(t!y^^^XO-zHgoUj1)mj{`nnVZOR> zuCVY~+Zjbs2l(br!8pY%ly5IT-feNat*y=Lu)41t@4#_{Yj&>8;9-CH@qQ8V$Sr^o zkdzsW0NB02@zM8nxz0uA!xbo!t%jB@_vxQ%*o2Kb! zc>#0ZCMt@28kI;NzRNa3OULcSr$H#^1jRL;<*zpBkk5DCd3jt4__3$8e23 z+)Q7$y75o62Wi2_0f)}K`m9+}^x=@ixa?8z8tXN{OG?^-w`TJS^; z%>L|M@3iroovF|{YTSsDaTRKXH6PXtT{M8(A~SUW%tY`~(CN$wdC19|E`Ex&pNi(* z(HII|<`A(Hp0^Nl2<5U8IrctErnCc(sWorq_kVq24&TUB)w}My{FJAEBZB9iahiK9 za(#&7ZD_s+g}NKuP9z^P+k1vk%&v4pP$hDWNob3rnPrrWBxTa5L;v_m+BV`C%fXHu@KGFYGP`AZc z*`v|hX{woA=Z(RBj7eTRNo7irP9PDGKe(;tJl&xi?HFFJV`%gox{zmy!h2`$_p%;K z^iP6_hHr(ad``r?EB&GAlkiF8klD97b_qM@{hjk#@!Vmy?e#9;TSfYUdi)DPG_*|E z;c9(-RR;pb5uw4UVET1ne3X=*au3*dA=d zG+%eH%dZcNADH=hyKsC+^VHJyuh2bN@c4S4878pe?+F_ch9#;ZuXe@y|7v8f4G0#=ZbMNA77n=ovP(e_T8X~?WbUB4OMa}@bBf% z!p|OzJq<)<+3!shcs(2#R0Reu)AvSgXu><@_rJ5A6G65LJUTDA5@Wlpee;sC#`F~p zs>oHzWc;+6w|t==9@i2f>7yMbOP=cX%2h1D{jE&u!H)TK{|s5Ct#|}Zg9ISD-XVU3 z0Fg}$MrFPB(E$ryWT;+mg`U5|yj?9rU;SVghs*=ABhKeRe6LUE18FZiQ=;mvXBXat zlAopf`~Sw|*>$b*Ubee?ER;*LDLt8cy9P4Pv`jYeYZAU+!-h`6j{lI^*J-q_6HNNG zIm>6=44c2cdIf07FlIymxP*l1D0my|GWz!QUEbegBe>N_w#BH@Kjyjg?fEwpX{bYQ zR#U6VAP=KnmYXu|N^P@on~#kgUSb^VkD)-%`_+7HmRm2icBRBB)!clJYF?J;CwW13 zFZJ~U_nXGkFL@umF6S_z!lvt2ocirYzC&RDRg0!oi4ut7cA`$rC#OIf3i7J9i-N

zmZPD&VVe6?kl*!hs?x-^F~AiSIWZs`N!899l5^>_H* z%nvq!N?YQxxL0|7HdJx8O5Z~EuA`}#zje!%X-2HRdoo+{RsOTZ_ETOe&SzmYWZ|I{z7}=cbu~0{OQ(V0zJe|$-}H+w zGYfx#qXT?AySc;Pt7d2NZxVDho25bPb})ufG;EQbi8jfd`eaH5s*8Twv}N3#SJksn z5jQ=Q0Y0$K>awK)+vIHf71wiUc(;v`k&@Q5x3>$m8NwH65+8c?5Xl7Sq~eDWkdQI_r= znp!JPi|GNvr&-je2V&B@w}2`!5L-sgow1v|nkdQL{D#4~ZAXmdSXfwA^EIZEk2NqD ztVO|R9k0RbazClTaU_+C|7y+{j6Jq|p>|X-sK@&6OTAT;pluJ}6xMp?pVqyZl@q4tDMU`;IrG|I`@ltx?{+3>$!Z| z&1~o)#xp6K6XW2&a?9!oEfhp{JG>gWx?q|Gt4lbTrkP*>k@wVpTse($+D zEI$r=SF!q?hI@5k=#LY+$By!DyhhZMtX+hFKwe&6E?bEjj}WLY)SJ8xFwFQT z8qn!yarg(;y27(B(vVNFBT%!AE*dYS>tuHpZL>IiR$FBao=gGhCMY>huQ`dOG?Y?N zsh=CTvkFh1V@hY`$6*8d^-;U){ZGT&mWc@Fnj{lN>Z z7Gb3Oi%A7*1G>M6<4ylUVz`I7$RvTrzNYc7WY_r6Fw>8kpXhCa7ax?|{&H+3m#Z2X zCY~XozV9$8I1ATcnC}_l zyD$A<248sbTJ9w>@PRxhOlZ9`-1QyT;T;am)vtX$<*^uT5>tb}8V?|S)ue_t(SU{HdjiQ1;}ys5}Pt*55( z4OO4C6~S~L>K57M_IG7QHDb*ISp|bs9-#Qd-rCxZkI~Gc%W{#k{^dscyp_tYPc;N_ zNz#MB(k}~EH6wvbboO2h{OjokClyD!a57p(;XKSf)>Jo%ge{o;ob4cuySd>j%^O*h zNUxru7ek|FC*TXL_}Dt_6Suv~1RP}1F0`@l2x3cE5`Lk<3G(EUepiZV8waaSm(jo( zy~1MXFp&^A{Io6SpTxJGPBpwqMQhE>4z&|f z_#HF*d=nTeNY*FKa-GpKV(T4Y+DRX_xqEv3Uu!SG2yxu@w*H+~sr)k;Eeu`a*;g?m zC0lr|pc3vHiTb}4aRnQWGrts7VL!hu?A4{csAhI%<#pfX8@lf=%0RA9cjq4g31}!Y zH8*6%%?^y-WT8lC!vpWJ^ zMx)Go>kT@G*2utz=}|^VhpyfbwsHz{vFjL$PHmf6Jk-+r)!HIm>)<7;#%aJ#$;dHZ zsPIPnP5Pe4QGpQQ$4A)-q|qgM9eh7p%X&m#2qJ9RwfLb0MTd(4bY3_awv|Brcj*LLv>|>V33O3IN-VsstgPWaO_vGxI{GS6XE-Vb~ zCxW3oo68Hp73W3=?_OA=U^^X`c=kvFBa5=%YA3ShZ69!ZnjyN3|2o1)7xKHdYvxs{ z89Ii9X$v9a2Yh$!sJ(epP(#~{!Xva6x~5A_6jGAD=SfJZoIM*Fnpx|}asMbHfBQ2( z&~qv9I05`bK@PHw*_P+1Z6+6I}|d0kpn8Y2u73v~j1bvR0oef({P ze!l$vgHEmJ!Ak08I>&O`zP@L1jnKn_aDwL|+xEaXnTgXNj4C_xo1auK5|&bfy{HjO z6n`T{oqsnaRv_*sNDjEvLswWoUz=90qIG<|R z{yLEcri|Q`(Qp-)ckSe!WE{o!N6%@1Rwtfj-m-}XNJ>VQCkin01ib*vRYg493;hPV z=Gw1$2gn&UWLGAJ9=n}38`29A^?y(jWM`q8rR8g)R-MvQ#W4lL0LJu?#9D9D<2;Pz zn9e<7_c=cIUqJpbcuG48K*E}Pgizg%hdv(H46)oXBqG4!8W4ruN&m=Ql8CK8F5U_X zveDOL!cxaERra`Yc8b4b$Mst5m*n)GfYSJ!=Vu z9Dt|weCznZYciq>9B4Ya43=tPQ8Z={VQm_Xw~WWNCs_$Hz_8E*g&6Mo(WXVhW=y5_ zV%f2`+$h^$v7g>CUVk~8qxG~xc$RxaL*FlZn$-^Ry!301Y3mq8hDSv*c@gn22*y7d zn}7gdQ^AE-^ef{H1GGWxP#&H3Wju7=+&%>C>u zt<4$~Icyq+Um1r(pC~E)M%@{a0EW%aWq@1J`{eN3!MATzRhafdk`w2{-Y}cF<=Dle zCs4giPlPt)%Uui{AP3EA)t zC(j{z>$&VU@hGDMI$g%$*@KbG>A*XhxZ~jVF3zfnI{EP~ag;CkhJ~AaTZNeBn3eY5 z(B1eMfEbR3N6x=5E^CjIeWQoY!;RjaXCyTP3=+1ghQGf_fu%PA)%xMtt&jDm%P!WOBNKAvwsX=Hm%y!ys zaRx~i@aO)3Yftfi?g;_vF1lpRu?W(angA@fMm-3-{0S>$utd4jBC>Qi$(b z>rZdRMjfTQg7k)-WfnO1w?LiNQ4sl~F(|^8_2oz~96v9s+Tj-796uKf+iZ1`mY%-I z?26hd=ex{+jQI3;P%>@xaH}p(OgTG6+to?Y8T>83c*^0H!YFn*XN3M>DiGPf;O8YO z)+bfB^{D+221Z?%)u^foN5MC7vRX9HW2cf_J{M6ffD6=cz~-x7W@58cMq;&(_{7a) zzdbB}DyY&>o4-$p^O9KD;#Pm-Q<&cN~5t#FD{Iw_otV;p$)SG-fG-JmHFCcAX?c1I?H#afM@lFx`573 zggF6Y#n^EvqyoI{+$;>b52H2r9_@%9gEdpfM3X&_(|W_GhME;?;*HfXI?$@&If*xh z6^)Xq<`@OHO$ud*C{icl7Zk9xo}RsB*-ggq&@jH)`OjuA0uv|n+z8!-Sm!`pd|T`q zJDqHO3?m+A&4=4hp>eQ-1&BXu^a}DEmCiixwH3gKz5=C1FpiRp^z%&3YldOg|%)Gb&Xa< z36jdk>%0pyR0P?%{olpv75hA)gNk6Vd2hs-^o+&6c)TcAW!t5s-5<3bz48EfI`RrY zFwC3|2{A}-;C5x!$&*!peDG4=F(w8Bo%XfcCB;J4#^Owbm3(c!L{9wj(M|3h3G3qM zm4F)s-^|Hr?a}uq&(7Go1>!$94mMFU6fj7_TTf*r$G?Xm1TCJ7DQ9p_`yL;yBQDNM zZMq9n`h9#$tn^j5h zk1bI@Q2ITdbg3z_u{a!|348ka)bT1f5{ujqSUha$!}Mp4a_vgVAPso8y4^fM`W@;x z`Zz=3Y1d8pUm+OegLSOOX0;l3(L15nAa4<}W<6;4&^5`*J2z1(6*g+#^bVO^+)KxI zakE52eJ831Z1>5yrlr#G61mPDi>c!^Qbyn`dbSJ>eV@3)28wTN%5JA;+asYDQ~t8@ z=E$KL;J~*3P~(QyWkknrpfe82t`&Mf&mGoJ;#+V=#NwE5DpSINcl3ODneK`dUS@9A z%nF2m<1~RBmH8aPTAN2t<~+i7#mzU2H(qk^37Prl<-6{WHYvbI_eJuC>-G&P$fN+m z_0_b2N2@pSdNCtREyey+-B1%hr$Aj^RK6AOty>L-m$h+UI-TT!_2v2MwqiK0BQOVf zZ;F=VbgOr1Qw)(TF38-#TTzF(d7^60fBPZhcAj(*kFH&QO|4aig59gT7M7Wso^aviEb$QTR^MJ63xFn|Ks0&h z_b%u5jwdA*r-WWO{&rLkCH-?Pcs>izqW#)pxb;IA>``|A6@;_J0!?55%STs3nc32> zaCwEbp-ULk}H+fx`nP0?UXFGfUW}BRWWa*NQh% ztPeTJx3isl%{0}@E@iri`P|nHZ8ndq? zTMSa1BBoOJJddwUOR?PlUMUNEyOmt29kbSMDJVx@Uo)7N8AE#-RNp+nr(lQ&wjl6w`L5rAoQ z`FB)Fma=dAw@YU8Gi=BAMMRt05#*KP7t3q5EV-nAWAJ0t-h5tW!d6nE&~S(mB}Z4)V>1^ zZ+5?01AB&wO~*1@$wqv*fBkusaoDv?&U{nfW+Jq8vi>wLxiy>u;~#ZU4~3*bV1fC% z%?s;)x-3aJ7IbTh>s3TFBt z#VI&_M+_M4rO^b8f4Za}!yr|_@-Go)KZ}|QE~U0Pa;}#j2?OaeEfV)5Up%UZ@|5z| zRlAOPxDFt-tOJ zk;W3=vI_zShv6)@-{(NM6eDl+2*FJr*j5TJ4QgZy2|rH+nsNxXTk1t<4dWlD5brV{ zwl^NgoBpM;6i3NxBSo+4_UPWkFn9ZK&XOUGiem(Bl?L~)AT}GV6?Y|dv4p+;6CJXZ zT=;RvRjb~rQEGbggn~qkyrJ$MwK#RjUB@5rt~+BUazIt{*6>< zxxNjna5OVuqbh377!7vjDCb^Wtm>ZNx?8f|%S}H!svot%I7^=G`O`;`ETyv8Q>g6NwS0U0_g>NjtmQO^6-1!v2DFUn6Fqm|eI$ zUn$w=ZS8=*QSJ6qhi~@>uu2QfKro3bv%&J=!%+g@FrBo(k+$B+D~;GRb?k{BY+#?< zb5ItCwPS$-pm4Y?cmZJZ-V#`O3b~y-ajhUh6a?3_$s|x|^0|xs;p5veSsCK7;>eN> zF^G>QJG)Hv$$L-5W8&C$bIXv&W}ww5CJVyz3?waaYltuqUkVIK5TV18nFa&y*D3bC>|LuphFf3pBY53gi*c|Ra zZ)}w_cr_Uf-c2`&!X>@LJx~?fhMUOmlz}+SZC_6Ep#kK;AX&)~dZ+Tdt2b0Usw)@Y zecC2+C#wfqlQ9rYe?Gu7SZb|Ri>-`I)4=RU?@ix|@q8>u@Ex(sKeywBT$b1!artb@g)Q!sza!*8 z=6b{f!DFXhhvH3bL9+7m5=ij+o!MgUE_D;c(f05@h0c4oEY34H_^b0o*@MsAIesP) z&2bg&1G4}=;IQLaI5cA{iFk-Kv2`RLA-v;v_g%(!uGqbC4Xp7UQqy()4)1kS7d^Fk zOqWcc4%u*GS~yoEtKjcU@_sfJsRt3r3WoUPCJW*HClGw3Y&0}gt{_ji4N28VIZ&_k zzp0H^enUaO%4Q|c#&uw8jb4lF%B)w=-HE`wTg~+suZrky4$Bn!;veBdtq+S8ab(fV zygE7H&>~2x*sseew{=3nf>Zf849?oQI(^Ww#QP5hd9u|CM{H<1X}pS~`d>)?h2LCY zRT6frmWJ2wD#m`osrviX?HKiZC$iw1%I(^DPMMCM%|8d3`9#GMa?Cp}A9=OBEdx3J zwT0A-`tFv0`Xawgk_!guiouzxa>__;mojA#yrbow70Enq7}-|StcfEdbKVJ#Glf&O zXUY*r`c4N$7l$%^<|;Tiw;9_bGe|Zna+)x`S%bWsde~xYY&8`w7y1nhZl?~UG zo_>XTVN0^nnDd<+eR^Htk0ZDj+x- zp4A{-N)25`#>%K65Wd?b!>~KkBxym5C~6H&iF^W48H+yJs!h5&Yl=K?V`O5TLYC7G- zM7nIXea#LL{9F*6DBy7W>yE$bNEx%AX5jkAj6~y*yS@XzVL)(HQkWP-e#IG8i!ya+ zEAh$_{PSH5KFT~hh@16Z9q5P7%LPjX^7t&VQO+egiPq7*W) zRA`n)(W`&QOk@Y2j_Lglat~E7PFsHKf`e?LA^~+P>H`lU|AFb3HO`2;UXkZwH-Rz1)`mF@A{ z&fL3fg9Bl%{(*dKG__^_138tH#>D<<4zW90HZ=@}&$ zU%t!kO?UBjxt^a@M*FXB-u2S=JoLAo7DJ#0oX{!Y8r+LN0}FM}H54 zX*PYI1LVDZkxe1_76FO*;jDd(!f{j*{U0mud}R{d>R{dAefspOCYVa$>IL{?2|o}x z5d-gEEfS&HF%Rx=^8dVd|G}m0utqAcG82s4)V&2Sd6}veO7!qoo*sz0nYr|%4kjQ1 z_)(LU0Bll!;9R*opW_twyq&90oAX~PhrTuoLb)2*?g?cbqr6#zz=e<94;KKWPVznY zYFUS`m_5V8IozA5i^1zc@si?E-pQTq9)G-_r2pq61oKC)ov_LSY~i~s-#*{H0ws+x z)F1}uZy4PJ9T^hXmCk4N9LDYe^LIIXZEu`6J)@uBAge2h&?(3WaowJfw7d^ydHJYo z=D$TYO`Am161*ftS@%t`S1oiX7#A7Y?Bp_jDKvkzY;BE-vWWa2poZfI^~_DzwP{(i zE?TI4s)qG#+pA>>SsQ3rZX07``G9^ffAY_RnC0pX=BGh|sE6tKom>i3@#Zl!N!<>i zoIima@i)Of^7C|Q#sVJ)k4_+Vp&HDGCF27k-W$L9KtbDC1DO@3+6ztMa&q!4wwEq( z&UT?*((=b0_i++M-`nV`r*Qv82gcrhUxSHXJC~z>MhBGUW^*hf8aufcm2O3fPGuDl zxsrtGEthJ!P=PR5LJxkvNvlR20Gj;3Naa1yDmknE@zx-Eu*z4`%docl)OFFgU>1F^ zm#IKJx#T2OynZt}iRr@&CnW|VEBz$aEA&m~@z2Ur4#w4kk&0p|N;!?HVG>SfgT;l4 zY7(WT*|-EB^F^!E<2)mFa)#4L9pz1Ra{v5DhH$*;Fx9oQkex}?_*JK2X9>=wMc%62 zc>f~LDK_5OZ^h0p1rZo%wg2}!3VvC(CSUL15YxK>CS})}LN54_K_NRGnU1w|mzZ2? zb}QMNnMJa{H}t<5qwgnl> zLlrlzs_rk$P!czeX&?P9nyN0ZtUvoCAO(C`^$&xa)3v*zpc{(E-i9YvbofefT`e^| zF&Xnt`S>LG&422F_r=ww|8SN*6~5JTkLG~fkD1@e-O;+b0wYW7ribV_P0nhP86WMd zlrL(g2U$xQTWHE;3+Zn1FOL#5%G0g(H6+5ha{PMV%c_sKm99=*%Bqf^Y>vfnkfzxf zhr`C$6a^=KdHnvNW;eGQ2Yr(65|8F}SBzF2mN6PXL|eJqc&HVTuUU~evsM?`*grJ| z8bF1Z9=FnX`uPeqP@nT$wpz$c5y*e zW&vptrJJR@yGy!Tx};0#=3Bw{_xHzj0ekH}&vWL?%(>^DIkAg+>G$d+avRhWHIe2L_8AS*_Bro9x4`6q}%1vp1+_YU*|HYEvo$g)b*t zg_@2;^V~XBAE)7`?R1Z$eVZ5jH)|9H0{bhj-ux7b7DpDl#$|jlX!4ljGj;4^IKAR? zA^27G2^?J=%Ykq;M6Db+T|HomD^FjNC`J_`6#Jm%LL^)MxgE(>%SY$7YM5 z`mL(j45M?)7Cft^Uy3Vpwu$03LtsfeGWDyh9Va+Xg|M+&Xk^+tfKK*Ln+nz)UCn#D+KNt{h@K=@s@Wb|MADI+lWtaVs-wY zll0o3xj=(xNuLI_EW3w7e%z3;xa`!!oxWwDO^UmWA2EmT?veQc6OJ1L5?V zEc3WhRDCA~%{uIEy42tM)y}|I`(#RWpwepYX*xmt*^1^+HxFkZaXVOng~K~5v67x@ zO?!D^woSSNo7<&n5sguI4siGnIUbRH^iDN|2WIjcS_LJ%MtZcWr^s}64E%+{)4Mg3 zGI7$_b=KUYO5rs4LpEYXxHP?Fo_33=-qQo4kDj5>wC+F%1mz5lX4YQ&aG!J@ zgsQw}T)E~d=R?jvSj6rLwp}W2ysT18emwrj(6;S}jL({Ms}hhnwZMEExnFbrX0}L> zt3~e<-DJ=ny8!1nUlw$^L)D?XD|;*lq~wP5nvlOuYd%5kuu4ej>@<0s>rPggwy3;O z=0d1!tgqywZ0C_h@SGB9wg{=9P*tcLa*VmCT#XW|dgk4-r+=#Pa)HymO4#VVD_mQh z@ljXV%S{yI6VA5t&>PL%3XZZ z#oFQQIRf(5`kX68rkHRO!PR! zcWN?~VVj(Uu{UVWOin4r8#x!`qj@%qto~ACVO2hK6jC`0*O&GV-`ce%8#sq^5Ng)0 z5kprxD)(u~ZtYEGF=3wmhCb)c8- zuL6PF;=tc(bKL@6%Ep<`QFCq>!;(X*u)FNTD>)a84!>sj4cz&+N@Lb^reT>WnePnA z_vTPLLXr1odR{4{%ja6wi(|*@1k|r=zDu~ z&C!u|kE1;$sY{)4kAw9}o-#yRgs7>r*g2agDkMjAWOEGnt9cWdX@NG`PzNvCYrB_@ zs9edh_6xF-P>VJvuamJdUEzWwnq{DAVv6{cDZJ6f!q~W0+u*z9T4r`3+%amaw$W<# zE7~Cbce1&v)s*6=<+R(S1(UvV^m&^~E_~itU!Lq$As;4Oyw)_^eTYBZ>|;U}@pH{V zI;uzL74BMQ0aow4s&+=Z!0myx4p#(;Z>m3ia$4?ki%1u@01Lh#UvvH1n|ZDM#HC6e zK6s7v>f%JT^Zd-s`3n1U2YTPG0Og#xuUahoN_S;-MS|9R-IpKSBP@pnXRFjwng{kY zH09E5qN9mE>eUR|e)Dlvz^Ri138P*$BriTMX$OCl;2`juVPB zMPYUqHx2Y-xkcp4*-|;BIGoOI?_&4K)ZM#&TuW-lmPcr>&a#ET{r`UGi=A z7uz<4>ir+Xx$`#p(RF{reESWpSfAfl7cyfh%{^!QGy}N@qCYsYyMEDyZ?k96mU^p- zSO9|sg?jN{c|MAnrSXnN&p#zA-?Ywee&}ac=vKfHS)IuTWeESFnU(EMbKm1#M(HWd(e6~ zEurv0dk#(WdG%6M9~G4;Q%l~cdFf?QtEu^T1y4<MK{*GY@WVf9a|_pJ&@%oqo04Q(HedQLz0cIq>Q} zs@K7kRqHVngZu{;#^13qRzc=|)ngR(BRI$6q)3;ulVv(82RY44G)%b>)k`6Mk(7II zucF6NNlpr;*HPuu#Wu-K+RTdAuZMl(Vq4Xkjs_^d>of%8Ez_J@u=n1Rrm$U&zwFD07Iq@t8>? zvZ>~UaX)4DkyD{La_3$A?bH~zpSV{QIki_YI5WX7uI~`GuOt?bCDRz)*w0G(_O74t zkp(*U@z9)&9>9^%{*KqW|&_wCY=c7~O)?P$6Q|8u-*67J; z&8TqY;xH}HlD7;4SSRkdcl1fCKh+7Gi~$#d_l^v2`x7x@wh&_L2nkWN@7IQ#wQlB8 zRo-%W*z*nLuB}U|E8oCqM~RoRryYfHU1n^SDt{!y{hQwl=bf-Db;;3~MVYWGb44F{ zQNGHrZjGNGGA>z`l$pSrl+l_Z(;QBW@99-pmX_I~%JGD7J*gyh#CYyY%wTqNs(j(p zHU;3;l+}K4!-LrR+B%nces^8m*58id=}L16%z>OXgb&ESwz3g^)}0Zqx~x0Sodr7| z=P1kUtwT4~J(aq7fUD<*Yvw6@Evc;ZYbxrlBHp8Im7*;7Vh|X*yqQs0Yg1(@K^W^w z(bo4t!jW>6(BSq2jIDI-{eYAcGK1|cYt@R&ev)eL^J@)t`fT6qT0*FCp-=&+R^*}{ z%x*()`x;dhlSpD83rbJQNS9|zZPfs>j4*SUu#tKugf_BN!`J@D(K5(5~!&9zH z+p#D6_(OT*ovqX7=NWW>7C{g@VGciVbZ%i@2i4COrnRVu*#peUKcxd-B+x2D^6uG9 z2jC9Jaq0Uj=bVBmxi$NF%s-wIu8yFv%-{h(IB3txm6Enf;W1JGpElR=ay~Xv*e0{Z zjLNp6l`ic^;H{|Gv!6113z=C4$dtdESt1i?KdPJNLTr~GvB+}XBqq6jHoZHWv&b~h zw#v$wjT~i-2msykJT6b}^iz0%!Ogz;+`AjIhh;M6*av8_U$yA5hgC~;yYDHc>`1d9 z%0&(A+7LAN}wsfG)K7C;TXYZQdJa zGx#)&hvqn&i*g?KytOgxiKiUVP5oTvFHp<(zofFu7cj| z9j!m-ZcXcrCgln((9L#>@I+?fH8n&n-CW*(8of#w14GU*<|K7OW^*9!4|l5ziWL77 zyhy!~NH&)Bz08b$_x+bjT9_r;u!J(Fdt%Lwpaj(9h2{>3PQ+&b47B@4=&tbQmnQD4 zzgJFMYtEJ(M-GNiVXE58iGDC|Q7Ki`i$_RC6Ju`>Wf8PUqk>p8aXH|#LjPpaYGhGb z>SRfl>2bHiVM@3V5X2e6Cu|PQH7dAjOy4dF-JpJ9gQ-d>^|(!0xjg0R`0`eFyJW9! zPh+$R&~w`R0!!M=bTl+MEe#wJLI4g8!K)M=QU{2=ZdKP{0j8{UTkc-KEwI8r+}ecl=^<#N z+kskyKS>M#e{bs6hizkQ&l=ok@}2l^EkN*w=NnGA7}3YbH-n9~{X2O7dj`O$_S*Lw zFA44{5ZzDRd5nh;bo7VKVH*;Tht}aLX^@-im%sj4L2F@lXf@gvO!}->EFhbaEqTz! zln3;n(9dV^4UpOqUEH4wfD{qp_m5=~0CpZYv`osYWz_}*wkJzq9hyWea-fEGUF!ky z2zRJHAP)`ewL=d_P01OPTzX`UkoHA%TD`_JSF4u@=RMeb_7`(I0J^NuA0Yr^_0cV+ zx!)Q52Wyq~;b|_#v=Lw&2O)F`oDUp9&&}g>!ECV6cRs2o8w_;!V5e26{dk-XbVg)o zmyYVF^>dUW)AL@IVb~Sj?@|P&>B0`8ivUX1T+8F1?Ut$EXPf3)QaG0Mh=MM|+sD(0 zyj{sdzwD6$_Oz+jvLR@FeJ@eo?E5Ku%pp;m0t@Rk02^zZm477Jz!Xv0L#XkZH&_ie z3f-k8!@Ui0=F3gn&$;DE0f8m`>xEP3^3NA_!q38G>JQmgP99fORD}xz!Ka?Y|MiL+fYGJbUIlam107lXzdL!tph(^$ z|Nh?V*%JSr*9fdOj`qRMRW$>kOz4Cws z1+M_DY0ojN5svLWZ+(*sOiffePypEgvt0(Zk7A1f!5;nF+;lJ;GL}F!Nv_9l$Czj` z<4XZWIH&IM0A>1q90q{^{ET#JRk{9v=#H# z_11tr%BLBlC;2%D`%$d#;Fxvi_y_i9ctd+kwNF-9-k0pED=sWfn~hSBu5{#Jz9Wv8 zqmR1AMKfo~d=;k9GTp{t*nkGX zg?cnpD|&4{_hc7~&*MJ$;AY?Vrd6-*)(9}DszesmkL+dkdQsvQNq(E4w&z<=mS~d1_+hTJO1R__zXzgEu|)a2%8tOL>zK!+Hl;_IK}L z3L`%iE$Tn0Aq>5PTt>xbo##4|Be#hMn-+w4L3fuhmmN6qlGHpSK3pt!A+X9EwEf^nTmz~kZrg^n+zro`qsh4!9Ec-4@8n&^Ev2L<0rEgJ zQ2j>w=YyEZaqrzCw(IVq@3y?|_3B)?hLwN5@*TMeB>RQz{QiRuRb|g;S1oB zLRz_u(=RfnJq8Lxu(FP5Lj%E{j&B8{y0=AnYm&)aIP}i9rNxrsP6Qu-w3+T1EhVJJ z77ip`tY}Gn2a&*UIfz{ZJ1!J5k!$fM<*yL^-&?~|QNbHfX|*2_wcWo(oT;Lr-+udr z4RH=NCXchw0|zR_-;jt`KGe_+ko@@~X8--vZ!Xz}aNyKW)F(Apg9{qr!Mxi31YirG zf&R1YQ22qw7Zx!>@;rH>_KO0~aiAWAg;h~T{nQ}U-=C+b`Tu<}pw>=HLB-1tY6qIr zy;8;9!{KW}V&@ueF91cfL-4eOVKZt%podcBF@jzqWiJ3zKo-ac%YhFgZx`d;R`<{# zT8H||ypW{54Yno(vQ}X)P)D5>o(o5i^4J7%u$XmM}(B=!h#^TUzk``1t z77O9jk(Kw#C4pZKgRW=l3|JnBSN-sC7IC;P2W>CXj~45M(;pY?i!@+H&R2#opV>$A z0EMLewj0W0>$RSj?d{QAo*|G5rM=ScB+lv6?hJzc?#L&da#O;zIt#SSYXamh%nQ#I zKj?0p_MG&fH+cEez^+2Os0X~>(|{z)?>xGSi~nL~@s|*HLR41s@?4N#e9P5=pvxM7^n3c&bz@)K zl`=sgR!QxrYcsYv$Bhtb+e3a#Mh#KtiU--Zm)00hcFlB;E@7LI`Mtb<^cn5#)SSk~ z$Xc+gZSM!}RB|~{39QaYAK0TUZ8wJ&?t{hU5K}4=$WtEQ8=sy!K0$uJQNp?EUzky*G0)8u1M^uHzEc zvui{DVL>Na)rGLeGZf(5uJ$k!ZZmmGreSHyn1Gm5^hs<`&Z+a%w3%yaE!<{usPJj9>6@oPoJ zt3);MkhWY*r!@-Zu49=Zr2(}-?t24{{CNlD9o(yU%u+3+xeC%QJudI7@gfEeXM35T z;&`h4^SZJLj(}WSmmhFN-Oo=`4M9cD0vNl&WRR_5!1}ktBibNJ3#Aydh;1oD|3u|T z>+;?T8Bq0~e!%j`WMCi$qa8{C;BdUBNHOj6Agol!x24_HcUbiQ@{hdP ziVcW~DVJ1~=eFfp6r+!r?3f@mTHqIc6-n4)BOLwuh7F{RY)B)%1DK`npdp+nK#vL~ z%m*2{|EEy2Z&)xN(LltRB(dXip5~YHsGS`3icDZ6rgsKdF`_@@EO#R6#up~Hgkk@Y zD=$RYPncOaF>KL8kZ30|9i4DqmZYuAL0N}jOk)u zXOqmE72!VYw1AJ>GPlQ16)hHV*M!l0BVm^r;Z!(@(b>5VbxyKWLu^c}@$W$fevlVx zimcO^!8BaRXb+8!$k~Sk)leWjQQQ11g{M=zn)M?D(cM(H)r?AL52RCbd<(^@OnTh(t$wm-)F7G zA2VG9Sh$#yoeA31 zN+SxQ{u3v(92?>I{{}jVCN>1eLCt*NJ4l2^oI}|U`ug8T{FBloF$S7&xhZ1y9Lk%D z|Gl4L_pXP+6gT0d^{AM_7&qacObUfz${#?VZA5rphz-OS%VaAw&f+EBc-WvQ* zR(-#ik&nPzCdUf_{t$eBG?8)cTT* z4Qo67pHepo#l;N*yCAQ&_t8Px5rAI9v1d6^RC~2c#toKAsUDxtb-;L#Z5ZQUreDo7Fp#v z%N*wqF+}FiQR?JKw>fRoB;Au8Gs1+$W5;0Otux1Rnv>mH0fk_N@KhJkd(I~c$5s4! zmOY3+9PqY8b1kJ^6ja_pnKQ=*M%(Uq=qMcPmdjvN_d}`&1DHTa2O{Z=Fv1$eup}Ch z&u&R{!<6Z^n8|Jc>(xBW6S6vn*U6hj^zl;-YAQm8QiO=dloH@ljD-r=_4#6TS>WA! z;HW#^0u&(sRv6NtwKutSOgsHL*o|V)wr>}2$aOP_0}O7K+*?E-pr40EJ9aBDiuU^iSg(v+L2?F6&K~Uk%~?U#U4= z1Nak2Kom>*H1e>F5fs%x7Pn3h{!iCb=3mz|RBss{!iK)aliH&T-VnU=zIsFNd!vF_ z_V9YlLafn-LJrs(MhHOggW*jL9w~b*+yc*}3DRsK@|f9&wk{)F*1JVTMHFR*OqH48 zx-?qmu9rlc?mQBEa<@1}@Ggh&$kw^SyD@xM4|&tF1*TpTiFb)*LeDk*fRR2{?!!{? z`c${GYJ^Bs*IrW(0c>rXl=XE_vQonut#Puvq{o?-@n9Ux40`PgdJq~YVX4WWZW-;i z{hsZRtE^E(vAxE0)#DbGuMR$cc5S9C_%>(qW}ElXz0J4T=l&-04=ffIDX%&dn!gIK zlMG1O8)bcoPTHKS3-hXb`|V|A8*&i{Sz^m+bxlJJOM-HMCa?zibJzf9JhjiU6ac}v z?Z)jTs|TT}&*_{n4u&lwiJEkja5A;Dl;ro2H5zVV1Z%DBx8%vsUM3>>#<=kI9kwyE ztv$2u7+n$7S8CM(LUAWM^uVCkPWSAP889KS*j=PA(DBiwTJOP<;tRYsk~)Ghm|`PG z1X_OkR*wkvj`5F6Oc4?}DS`w(mohzW<&x$wkXqEW$6JQ=!B&(SuU=>w>#3H-A+GAD z+<)L3QFM>%)RxL|TrHC394esHzf&k*rvuyG_)BdLGH9Cin|uuTY@L95Dxe!qiPK2lFm z*dH@KA)D5iNcT@X3KBb6N6%;+glRIehKcD`-|9!?3Ks3~9hAqOz;g4WTfXlWwa^p> z+7IR(1W`V81Ak@_M-SZ$Gy%of%ZeUiSjqzoJHM7wbdnPWEpddn0?Y7W;U~qQ{2WlD z<}Pep`1yjvY)u2qH0Qkv@WMQ+rwM7jch0Y|R!m}C04$E0K1GB~IO{)sg+JgsQb}xO z9lNy<@&kD59+gJ?rP84*lVfQ!?H;+m<^H{go2NJ4`>C7Nt8gN_LU5~N!bvco_q{id zs;;Y$+Yi7Gu8~a{4BfP{x zQ3bpjr@a%Lfxr+`G;Yz6z`u7~JEA~*Nc`6oj%3bteBv&Mh?FSBybs%4VIy@C0xF;* zRolSj@K@NFlep@63iNCo`AV8+fCT{ZQoK%#aikR3b)4hTxgWy(gTvfU{ag|?T70V`Ep^~oh72nsij zzMKnm8)O|+A3Wy}FM_NnJuk{bcNG^AR#Tk!XHww+(3A3-D`lSVzu5m2>DlQECvx?U z`17{bAgHFxs?^5K?M6Ck`)#VDf_|lO zfAH1tOR6M3FRGWB?uRid3QZe5$)(U;^yd$*#+{ED_P!Z=@-BOYdpJ_Ob&lxDey%pT zlFj@qN&;75`-beaz)F1*o=5W{VR9$NP~k?T%ralXx#Jd8q98}lRl{GGcSdT`Whl;j zvpG4)6?jCFOa6R*GJs1!JzV=X(2H2u3To*^;iM4rWg&MgEN09)o!dmtC2^mq2IE9- zglRd&$EkQM-4lspTlI~2z0Xcv<1iQ<7~~{5dx}=Y;+S#1e@43i)4q+c;~K5-s)Y!+ z{^J&6wo5Z*$HQ2z^Rm?sH=@~ewxE=B$1lTIuzL!gTti=cd*fPnEVx;3J5A^+rKqU1 z{?;L=7^H)Olq`-x?7kp-0ug-Hqgg&;G+!bshZj30U}zlp?IXJ7=f9aj63&1bpOI&q zF!^y(tq77cabN|~U$H_d=Ky~tTM`Oh&R)T>y-s4a^W9GVmEZP2u|4|&iy83OomEv| zmgl1Fcngw7!@F?3bN{KAs`u$|eF6rdDf5jPj!0JDv6U{cf$p3YLJY$v;7rrS`iw|u z37k0=`O6y`%2@4MLSm}iujqx57X22iV*A_MWL%)zqR5PY!;s2#6 zB>sLCq${1dS}-BLezJ{l%u|eD$K8jb7C}|rU`l|3{4F7PkfvS&?JwhcN+xo!xwx>> zg8~^KGop+YzLbTNj61r1!n(DO<0E9S1`$-|B%T7WTC{X6IxZ@5pGK#x{?45I{f&`h z!(0d=ehiQJa*Qq!B01GvM`AwV;E@J2F5>-!%f9+*pbFsrg z_6PqU-rO!^;@r%BSkg1}I|UaBd;Z=N?P_o9&bhz`%aMI$D0j)5V$g*m^j~P-97B?0 zgO^@Nr&cy60MTGDwx?2|I6Htb5BL$=Dfx-TfA`HKCx9|5%@Al5V-IC&1iRAy%f;Lg zx%q(SmVp!zAc_lnX`8&C-wThxoxc5!uYqX%UIkHSN8_7v3;!e~YGy;M zgGo4}yFb!Z?Qc`0Nwv*z9`OPA{8aqN>_Pl;cl7bR34ofI04O$_jd}fT>7#3j_p~)8JRvD|1QQ$n3|ri*Ve1QwOie+O^uy8BjAMwfLZoBCVVa~uvD`u;LUyqnO2)36j0O~K$G8MY-Gri2z;WMgG z`Ay$x{_27iK#cN=9U!(^dG@`aerJ*v#2io2s=wjN_gHILSzA;y!0}I$CqrzN3(0_D z&tPFsJa7q6tNTd3YDJ>l>#YfNG#Tln>8;3|7<{D&w1ksE* z;VofCkJLelEH2sgg`&C_A^7m!dVJ9K1X^q1*kAANkH0smnYVQj{^wy?fD0BtqC#)B zpBdu?k>5MqCWA77{mpNHj$7wv^??Um(w4#-ZUv?&i$VGd3)clu>0Nyi2?F8mk~UT2J)24O$3}euUo#H@T(#B(J0NR;(TkI*P677=G6@hZUc`!X1*ILj zlN@|?QJ+zuF6&yk{hT^xf}vvivHjx+uBr~^*vDJJdy}9GH@|{hZc)?B zUtO&14i-Si8t=p@M+yny??+)<05JV|`5-E_*M9X`gYin1RpdJ#1`yncqd5`Ce2kV3 zM>>pi;|OkPftVaIv`BHZeyR+6t= zg~Ca|457NP2Zk}YNIyKtY_eoNb$5D}*7O2$xseOv*;*;VjAQ!+S-F&fUL%o%HEF(- za5up3EWn-*h8BBgNyT1&VMd?OQw=ueAd6hPV1W7CGeYJY)B#RF`sh8Jg~Cxl6T5ZR z?0C2G+^F^(fwRWRK=SYvG03<++|4&uemL6{Kkp99zT_Fvc(^fuumOM?3}Cqb%#56` zvigd;oK+8HF15qkm#(a4DVRtVfT`R&z*r9@PR`4>hc&Wi73kEKCjNG^|7=e zcxWB;rMvdG>Y~q2$ zAe{i_WvEauH7M2GFn3zgT>|?>!kuV2gH!+bvi{@*Psns-*?zl@J!mL8u1gIrAv~T0 z**p(HSHoZ6LcBh!uG9O51rpQ2tlMaC`@ZEi)4^=^Cy~IjTo#;V!Kh@3_WhMCGa)-2&1&!jM(nqA_nocr0_eN=+alU5JroA?>asw5HT^q z`?Bn#BYLCxk5fi1ilw8hAh)>;HgY%q@(Ls~Qw{etxx`LNKYkmU19P~e6}!M0OI;?+ zHyY`Vrt`_y-n09~GaP7iwoMi2-<1HiTX~le5x)B-lP};~C<<+Ab57`FBYlsZpKE0U z`Wp@8GZ&Ggf^>)PT56iiq_C-gI2Rp>U<^-r7a4q(LlZ^st{<-YJXos9K! zh1f!-9Ropm9ESQn{88y}eA|vI1+Mxy)pa)aZoHEZH6v-HXoyDnN#1P>x3%FH9y>Ii z?9~^|w(Cu0Ddn;AQdSBNy={Oegc@Ic|FCn8=I0)}Kh&JZ*YL(;N9K?s?W-CyI84qX zcKK?=Ro-hm){c|w4n~FHab+)Bi0$z9*fzpBvGFyaJ91oAC|LNG3&CxL?fdY{gjPrV z0V!rt#ZdU$22J?c-+TbMxK5Itz(CMepp~BrK1|EYI;;8AMez?|s1Kued)!T~4<%5! zL#bY+3p8xbgzJ>uj!7d4uuT{fz@PaBH)PKWyk^>4+GiG^tb*9Gm+^NutDaXd-n zy!o(9_vwkAmyjweWKGsoT^f(0UJw{sYiJ+DbS|=HBA4BG zkLATV(#t{b#Pt#y*PXX>Q!n(Jvhgp;m$#@m5FzMuLuagV1m1S*m9xzi*VU2tMRkdk zY_CXaL2qlWbn@=K!7Y{)!V^P};d97Q0yf#$Z_1|$YF|)>h$Zxie&JE{66Dadb3v?j zINB z>@w>4D$QH8DCw%Z{|;xzXJrG_9}R6pb6g)6K-Yh#p6lQLq=_^i-C#g=E#J|&hXw0&d zB6rOw7U*qyY4U>>ynNIj`iBjG@-4UPlOeGmNII`cT3h!F1cQY7gCK;H%`fnSkeV=03X72q`hMVhKm z7k~ccg4Y@uU`4gh7oK=@qjm6!CUH0}X%9xndv64k&U&k{VEh?@)66^PE-9(A0ooVX z9ahvfus0mGCfi+Xfin^HBp9*!%3)U#BHg*{bI_AU*7lY|sYq7>5-$?sfgd77Y_`=^ zmmS#^XA#P%13juLsv8ex^L&5btd!ml1h;X-tX+o9dehk`r1Gq)i}F{33~MZa*Y9et zJ0oA&(|ZPZsG9He zSVidVzD#S4l*w>GVLSRC^LIrc@afA6M2f`I{cH=v_kPy9`E@IYrj0OzGfOZWDRsU1 zBh!_7mqSOX$3%@r?Nb;dHw)DQ-dhkMx8Pxv3AE<6%Ru=j^Lw;ce50Q<5#dNYOlF!Z!Y@ z#R;s(!Zy&C+)E>iTPa~Pc*Q>raKJvRsKbS?goVF7yZ3#llM{WgA*SC}ytE55tBp5x zgcu&8Kr8U}GA_M=y(il5#3fcS&8d>GOBhEVNaIZ+RpOa~28s46quR5L0^$h1gd^D% zq|`q|Pw+RP04zM$PfKT!P&7NFU|DwITK+6OBCc{b4`B41iFYUKWYNQD!hfC#=)r2g zR###=5;qyd^PGN>2>T&+@w4nL-dkK@=oev~!(LK&F3au40_9Bh8OxeHBHtsf%9~swNdJZLx2T>aDTX4ogs~V zue!olc+6hPG0%n^>Sd0AfNlp@x`D%m3|{eoK^^#n6RTXG-+3CdzMJV&w;S)5Cad%= zkziV~b%RbOIN3z_cd+2|LSL^q)ahUCn|58Jc038`V|l-G@IvS22gl)^nVHx2NdO`Y zJ8@Wv=tlMAMXq%Y3uE5l?ux9B%foi(B#X5>*6%MClwH}}K(}40)eFA+2Ed!`HNAE? z9XbHV*Jhripd-l=XGVx^2$7OOCxKGS_1llZ8=vUqqDR}&Ozn0b@b$1_tU~sv`!)~1 zW@fb4Ii0Rp$3z_e?BHX<+Vq3E^9}?GuJd#z{X?_SiT%*;PHOSegGI8^pR$Tb-y8fx z@R1gH@Mnvr!yIOPQfq8PBns!j+NTKRBHQz$m#>d#k1LGR}p~mM89gNDzMDb z7FEfPShcuK-$^Zm;71mtqNWcA!{7`>{*>7xqi&(tScb)ffa}<75)xHHzUq(J9UPg> z7t1!Gf2mNI?@r@ks^NZ^@3r*IuFk>4kCFJ*qCE<#5Pb5$fdOX)G)a8V*YFO;b74!0 zEi37OgE?CoPsa9N-9~H39p;ztZ@1mA(5Ru?cO@2eK-fKs&bO`71U1AXryd8j%a3bs z_p4{o&a$n1H_fnvB<_<&r-$($xY~X`bv!U>xnh5#e`u;m&D*C_;8&hYjHE_p?ZxLg zg!LUwqjx)986_Wl96&p^c1eXw=lhF?C4fQDgR+M*P@RBjk`H<=W*ehmQD-}+jnVVe%4D}a*eHZR8uN2NiXV8g+{)d#z^pr zgl>?V6V3W}lPZf%0EVl)`fYT9F*kr!In5WE*+vaj&9d_#1aSniXrnS$f|!J3zH}sG z|1tQ@oYm~b%rH1hHm9TB{pi$kw)bns)%}mrkV*e4Oa?2ifk6YD{8nnhUv5ATs|wH5=~%wGQOqQvCNHSgxJr-;6S}OQ8BmBG*-G{#G{z z^?Ueu>3@-!B9OS>!B!Noq|ThD3Q3IaO^*KZT7QiBnDh@sWsHBMfHgdkXZ5<782fK5 zLesgOceI&5yi1ac>h^)x^4>uJhYSUQy&t`f9Wy({a{^G>0M@j8cS!APEz7X0EzEm^ zC|KKIj;>BUc%rd!2}KnhVuH8kad`9i1jJO9EE0!1@99~!VX%7$ggGCa;!S+xmp&=! zn5SdHrt-HQ?y)PB+RwG~v16ejYxvP-BIK0pB7?bwNt@@6HAq!)X}@5`6n>)!BH+9I zXj)|l?~I;A+vj0pyhI2PhFbdvnGScJ^c9((!D4N z@M}0-WPbuuX{E_2H7r$pO1)}Wk%sxlqb5I=`8&(Et}tL<`h&m#q#lG;rW)S-K?<(V z2v+2!Yov*PwQv5rqi7+tiA8r);wrUdC+J-1a}Dh~0x@f$E3)5LDFKeJI0hjWVGyTp zC~sdiA}?(Xy8m=rBy95W*vz{ph+>`?Z5;s$4q+@3gYltd4uG+oBtZKY1q(C%%U7L} zzRqT^UB;ka6{GH(ZtH$&X1oMJVe+`c!(=P|5(FhyUTuemuiN zG1&Y{8h-{=P6|?+fd2v{f$k(ARAux=D%DIToK(c@X#dKcQFFgTV0t}E*P^wCIp52x5|XT;*BwA;{R>`NeNNT&5326)rtb@0w>KSL{JJtYl8U=A8tF zB}kF#k8Q!w{y-~InH@rlCUcphX~k+-k)`79y9^0zL6)V{J!K)e+_x?);zRd(I@nZW zrf7;nqIqu-{^Stp8iQDF`17gI_OHF-&=j>Qk&Qs)BNwbR$*e;;s`HIU{L$x8W{n)0 zoMS8fCiD@dzxQPEI@FiHKZPg<#CDlim-A7)Kf*PrY*}_$?PKlWj7Zw?hyEN(d-37V zgplK6egsS6f~CYDq`|`Yx*)u_2S_u303Xequ>1xbk%B$B!CBPbUv%c(JPKbHni+mW z@h<4>F@cRuQkd3%+l_V?^)!%&yMB})T4jaoWl0R1HBZ~}KQan(MPNH7lZ(hfYQluj zqF=o{HAiA%K5E7x2IDewP%61YA`Cj~$w%&#VC+rF$YW?*+8Yi&i|FkkODL9h)zNHz zNnrgAFY|ZOf#WJz3)|_`E0g;Z;h!A`>DNVbynbH8vyNEOZdv56NH8UyIN(&6MGzUN z#0Pfup@QCYJ&O{Cgv@Ro*}wi^Vb-x^`R2$^0nEjva}c#`&{e?TAEm)c0BL8IuR3g| ze#Rm}z=8qQ==SwQpTkc!7R#N~{5>lD(e7LopZh#L{x4b^ns7hQFJJ2A1Klo8rvd=h ztL0s#2%T=ihZG^i)Pf_GrG*`6VX!*a`+-4^5D$&3J6;`+i%o2IFOQTp2Eb*6+^1yj&r30PA>S&l*x%RMx( zCT&}}BZWnMt1#s|6#=cSKeU7-vH6W(jFly#ZF|Zl1~w(Nyhmcujj5pH#WD)ux#PqY(FwX~7*=(?FdHLnhk+S=H1aA0 z90PtUUFV=EFGoCk?04}`a+wopBI>4UxQ4^w1$BjCHhKsK8@+n%Z0icBO0#7ER@D)a zWKMGe=&Ezk*JMn+s@L@mgI0xXN^`R|ad|059;@+5z!gYI;+ ze`mf1-ebm_!rqhx6eA{3_(#aqCdPAf_c6%^pp`qncR1m-_?gWJS>q`YX*mljp79> z=M(W`riS$2uy(*oUAPhw-_*XHZ0$MtbWCt{=QV3_OyZ?qaF40;f6ZO_I}~ag9%DVk za7>hJAxbd`Q88m}6S9}Gl(kF=SqC%75|JgwK9*!(vkzvd&yt;!-HbtG8Ai4l!_247 zKXIO)-XGuVdanC^pX+|^`+DsmobBHMwyFU;2=$#m&&GLVogIzxgPR5m^P`%dzxN=N zx@Eui&HzW+Zi>3w_)nxgs~f6n>6X#}SNz8#{`FfMz58PNOF1#CL#FGblcnAEN-1SJ z%XXD0(LL!1F!uUk{u&o0|FPn3W9DRziqZrm8(%OhHN73+xTo*4K(npv()t6-LH*bS zCclF(@1jR92j!`gRr}x%s;|b}w*Q3x;KZBi|AD+M98l*}{gg#j@0?A0ltVP)EdP*e zvbcFx_JT9iTWv}L_t*6?viq`kl%0QnivovHsWP!=AuP1$pOexU5l7^Or}6h?7Begdzh^#Y76@Zu2Av~NkB8&Hf-!s@H;}R!>j%JgGU=|oa2Q>@__IMo8o5m z(+k7^FL4R;;1RlD<022MO`09sN}o9aBD%pzA;_GtgOB50$Za(@$|B>FXVTCH%A4pZkNzQUS?O{ggO@H~J=vUT$85cD`f5vip+ zw=3HXoS+}1-Aw0oywNp$o$ls}q`0^qEgRmhoMaCJd;)9fjDo&e9vDk=Xi3HoY27_L zg7wYw-9EAnx3_3gACIi{To6~UqK>Jwo`01sWm)DvGA-@SOp|GS72U$7V zQGje*-=C}<4c<$Dl1rN>b7S?P*=x?Mfu0Wr+KUU1#`f%gNQMwK=2{B#^*F!$#i-s+v@-(Tj% z_F#vD2ky95oF-dE$7qgLD$IKs7+5mR5cDhv%gh!leYOTXs|k`6f5>nxjWJFes`xUM zAva6z(lpM2m~=k_q_9RgS`yTJPBSP>pj-wAxFt@)X}Inr(>G0qtT_=IcULTI4{%86F<|k3@;-jVM4XcwSU! z@g;uD?{n-#NNN^mQ<147s#}Y4o~Je0Ej4)C$^`1Kn&%)ay!ZD_)L_J!L5NJZz8vER z>nJSni`?kgS0djp_NVTippI3k)br3G-=hN0z9zxXOd8G^c@Xr~W)qZ9zgj~gJG8j{ zov^9SnHnWdC%N}vWOfUK*5PN+wof{!0RmnRp4?_>{Agt#pJ@Edo@rx**u3vlXeh^6 zdT}+1Xf{DvxQt3MAY1|}KrK+)FO$xrtRYLo64b->3D$1Wa$9i`$J9>hEl8x1PmBm7 zNL7#c*fC=)`ykght#PL^tYuZjU?^jvm5Bb3_e=bk%`3$znG)MeZ%F7rQ!}9$vz374 z`Bt|)VM zL0cG(f>pKL>s3NO4d3UgscunVZ9442VSiR6LV2rZP(SyhckcsR)`V2^m%1Nn7X)XB zuE*bV0DPxE9zhfXVSQUwzG>pw7cvGgB1`qKAGN}V9^mh7>26$>TKW9ezu5!zG6Q}Fp+2tzrg0s6?cPqS7NH}^%TMx4#v%-$Y*fw zIhpIbEL(n5E^kG8hRNGHsbx2W`NE{qA-~(l*IVOan+c#&)odWaU*n)Y#z|(vi#7@oa7dDra5+g0;r;B=+u2~;AL#?5 z)h@#}o88)|WEHZ8bOVT2HwR2JVTl0T0ebMf7ARAH6C>>hV+r8rv1AY3JBNNE434q& z;#j}!6i=?Q(GQlJctv<0^nC1_8Don~La|U? z%dFXXvG@O~*PjkPAgTN)keKxc4~SLjaY~fc-Z2hzA!IIpj18&Sl}XGtelc5A=C|QU zXW;s32$EW&8DNw zgd{~L>bX4sNZp*bMU8x}3)8gGDaJGNBIys!Li1KH+wAMS_pat!jp}jO{bLQq*d2I=VM zBH>zfGT{*Dk6IicrDW+o6&i(?1121XzG`9{FDgaTtk`_uH4SptRdE;q(oXp0}G5$LsA%+iQ$6B8Sw@eju;KpkLIMxJU6C^Ts) z4d{%7bz%7h6u=#_l2VwJL5BR8bFHLaqh=J0 z+zm$3Fpz`q?}`1{iGT1L^>a>_vWs4mCQm^sb&HLZ2VWvBX@AmNz m!~nJ< 0)) { + for await (const sm of message.systemMessages) { + await this.onSystemMessage(sm); + } + } + + if (message.cmd) { + switch (message.cmd) { + case 'call-start': + if (message.mediaServer && !this.call) { + dtp.mediaServer = message.mediaServer; + setTimeout(this.joinWebCall.bind(this), Math.floor(Math.random() * 3000)); + } + break; + + case 'call-end': + if (this.chat) { + this.chat.closeCall(); + } + break; + } + } + + this.scrollChatToBottom(isAtBottom); + } + + async onSystemMessage (message) { + if (message.displayList) { + this.displayEngine.executeDisplayList(message.displayList); + } + + if (!message.created || !message.content) { + return; + } + + if (!this.chat || !this.chat.messageList) { + return; + } + + const systemMessage = document.createElement('div'); + systemMessage.classList.add('chat-message'); + systemMessage.classList.add('system-message'); + + const chatContent = document.createElement('div'); + chatContent.classList.add('chat-content'); + chatContent.classList.add('uk-text-break'); + chatContent.innerHTML = message.content; + systemMessage.appendChild(chatContent); + + const chatTimestamp = document.createElement('div'); + chatTimestamp.classList.add('chat-timestamp'); + chatTimestamp.classList.add('uk-text-small'); + chatTimestamp.setAttribute('data-dtp-timestamp', message.created); + chatTimestamp.innerHTML = dayjs(message.created).format('hh:mm:ss a'); + systemMessage.appendChild(chatTimestamp); + + this.chat.messageList.appendChild(systemMessage); + this.chat.messages.push(systemMessage); + + while (this.chat.messages.length > 50) { + const message = this.chat.messages.shift(); + this.chat.messageList.removeChild(message); + } + if (this.chat.isAtBottom) { + this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000); + } + } + + async joinChatChannel (room) { + try { + const response = await fetch(`/chat/room/${dtp.room._id}/join`); + await this.processResponse(response); + + await this.socket.joinChannel(dtp.room._id, 'ChatRoom'); + } catch (error) { + this.log.error('failed to join chat room', { room, error }); + UIkit.modal.alert(`Failed to join chat room: ${error.message}`); + } } async confirmNavigation (event) { @@ -295,4 +419,47 @@ export class ChatApp extends DtpApp { this.log.info("createImageCropper", "Creating image cropper", { img }); this.cropper = new Cropper(img, options); } + + scrollChatToBottom (isAtBottom = true) { + if (this.chat && this.chat.messageList && isAtBottom) { + this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight); + setTimeout(( ) => { + this.chat.isAtBottom = true; + this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight); + this.chat.isModifying = false; + }, 25); + } + } + + async onChatMessageListScroll (event) { + const scrollPos = this.chat.messageList.scrollTop + this.chat.messageList.clientHeight; + + if (!this.chat.isModifying) { + this.chat.isAtBottom = (scrollPos >= (this.chat.messageList.scrollHeight - 10)); + this.chat.isAtTop = (scrollPos <= 0); + } + + if (event && (this.chat.isAtBottom || this.chat.isAtTop)) { + event.preventDefault(); + event.stopPropagation(); + } + + if (this.chat.isAtBottom) { + this.chat.messageMenu.classList.remove('chat-menu-visible'); + } else { + this.chat.messageMenu.classList.add('chat-menu-visible'); + } + } + + async resumeChatScroll ( ) { + this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000); + this.chat.isAtBottom = true; + this.chat.messageMenu.classList.remove('chat-menu-visible'); + } + + async onWindowResize ( ) { + if (this.chat.messageList && this.chat.isAtBottom) { + this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000); + } + } } \ No newline at end of file diff --git a/lib/client/js/dtp-log.js b/lib/client/js/dtp-log.js index 58f6d85..4e51b74 100644 --- a/lib/client/js/dtp-log.js +++ b/lib/client/js/dtp-log.js @@ -75,7 +75,7 @@ export default class DtpWebLog { }; const env = document.querySelector('body').getAttribute('data-dtp-env'); - if (env === 'local') { + if (env === 'development') { this.enable(); } } diff --git a/lib/client/js/dtp-socket.js b/lib/client/js/dtp-socket.js index cceebc5..7868e3d 100644 --- a/lib/client/js/dtp-socket.js +++ b/lib/client/js/dtp-socket.js @@ -89,6 +89,12 @@ export default class DtpWebSocket { } } + disconnect ( ) { + // remove disconnect handler since we're being deliberate + this.socket.off('disconnect', this.onSocketDisconnect.bind(this)); + this.socket.disconnect(); + } + async onSocketConnect ( ) { this.log.info('onSocketConnect', 'WebSocket connected'); this.isConnected = true; @@ -164,7 +170,8 @@ export default class DtpWebSocket { } this.joinChannel(message.user._id, 'User'); - document.dispatchEvent(new Event('socketConnected')); + this.log.info('onSocketUserAuthenticated', 'dispatching dtp-socket-connected'); + document.dispatchEvent(new Event('dtp-socket-connected')); } async onSocketWidgetAuthenticated (message) { @@ -175,8 +182,7 @@ export default class DtpWebSocket { this.options.onSocketConnect(this.socket); } - // this.joinChannel(message.channel._id, 'Channel'); - document.dispatchEvent(new Event('socketConnected')); + document.dispatchEvent(new Event('dtp-socket-connected')); } async joinChannel (channelId, channelType, passcode) { @@ -195,7 +201,7 @@ export default class DtpWebSocket { } const event = new CustomEvent('dtp-channel-joined', { detail: message }); - window.dispatchEvent(event); + document.dispatchEvent(event); } async leaveChannel (channelId) { diff --git a/lib/site-ioserver.js b/lib/site-ioserver.js index 0b6595b..a4790e5 100644 --- a/lib/site-ioserver.js +++ b/lib/site-ioserver.js @@ -259,7 +259,12 @@ export class SiteIoServer extends SiteCommon { async onSocketDisconnect (session, reason) { const { chat: chatService } = this.dtp.services; - this.log.debug('socket disconnect', { sid: session.socket.id, consumerId: (session.user || session.channel)._id, reason }); + this.log.debug('socket disconnect', { + sid: session.socket.id, + consumerId: (session.user || session.channel)._id, + joinedRooms: session.joinedRooms.size, + reason, + }); if (session.user && session.joinedRooms.size > 0) { for await (const room of session.joinedRooms) { diff --git a/webpack.config.js b/webpack.config.js index a2bd9d5..4edc359 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -68,8 +68,8 @@ export default { }, performance: { hints: 'warning', - maxEntrypointSize: 512 * 1024, - maxAssetSize: 512 * 1024, + maxEntrypointSize: 2 * 102412 * 1024, + maxAssetSize: 2 * 102412 * 1024, }, plugins, module: {