├── .gitignore
├── LICENSE
├── README.md
├── TeamSketch.sln
├── design
├── brush-colored.svg
├── brush.svg
└── icon.ai
├── kestrel-team-sketch.service
├── preview.webp
├── publishing
├── linux
│ ├── README.md
│ ├── publish_64.ps1
│ ├── publish_arm.ps1
│ ├── template_64
│ │ ├── DEBIAN
│ │ │ ├── control
│ │ │ └── copyright
│ │ └── usr
│ │ │ ├── bin
│ │ │ └── team-sketch
│ │ │ └── share
│ │ │ ├── applications
│ │ │ └── TeamSketch.desktop
│ │ │ ├── icons
│ │ │ └── hicolor
│ │ │ │ ├── 128x128
│ │ │ │ └── apps
│ │ │ │ │ └── team-sketch.png
│ │ │ │ ├── 16x16
│ │ │ │ └── apps
│ │ │ │ │ └── team-sketch.png
│ │ │ │ ├── 256x256
│ │ │ │ └── apps
│ │ │ │ │ └── team-sketch.png
│ │ │ │ ├── 32x32
│ │ │ │ └── apps
│ │ │ │ │ └── team-sketch.png
│ │ │ │ ├── 48x48
│ │ │ │ └── apps
│ │ │ │ │ └── team-sketch.png
│ │ │ │ ├── 512x512
│ │ │ │ └── apps
│ │ │ │ │ └── team-sketch.png
│ │ │ │ ├── 64x64
│ │ │ │ └── apps
│ │ │ │ │ └── team-sketch.png
│ │ │ │ └── scalable
│ │ │ │ └── apps
│ │ │ │ └── team-sketch.svg
│ │ │ └── pixmaps
│ │ │ └── team-sketch.png
│ └── template_arm
│ │ ├── DEBIAN
│ │ ├── control
│ │ └── copyright
│ │ └── usr
│ │ ├── bin
│ │ └── team-sketch
│ │ └── share
│ │ ├── applications
│ │ └── TeamSketch.desktop
│ │ ├── icons
│ │ └── hicolor
│ │ │ ├── 128x128
│ │ │ └── apps
│ │ │ │ └── team-sketch.png
│ │ │ ├── 16x16
│ │ │ └── apps
│ │ │ │ └── team-sketch.png
│ │ │ ├── 256x256
│ │ │ └── apps
│ │ │ │ └── team-sketch.png
│ │ │ ├── 32x32
│ │ │ └── apps
│ │ │ │ └── team-sketch.png
│ │ │ ├── 48x48
│ │ │ └── apps
│ │ │ │ └── team-sketch.png
│ │ │ ├── 512x512
│ │ │ └── apps
│ │ │ │ └── team-sketch.png
│ │ │ ├── 64x64
│ │ │ └── apps
│ │ │ │ └── team-sketch.png
│ │ │ └── scalable
│ │ │ └── apps
│ │ │ └── team-sketch.svg
│ │ └── pixmaps
│ │ └── team-sketch.png
└── windows
│ ├── README.md
│ └── win64.iss
├── sql
├── connections.sql
├── events.sql
└── rooms.sql
└── src
├── TeamSketch.Benchmarks
├── Benchmarks
│ ├── PayloadConverterBenchmarks.cs
│ └── RendererBenchmarks.cs
├── Program.cs
└── TeamSketch.Benchmarks.csproj
├── TeamSketch.Common
├── ApiModels
│ └── JoinRoomValidationResult.cs
├── TeamSketch.Common.csproj
└── Validations.cs
├── TeamSketch.Web
├── Config
│ └── DatabaseSettings.cs
├── Controllers
│ ├── LiveViewController.cs
│ └── RoomsController.cs
├── Hubs
│ └── ActionHub.cs
├── Pages
│ ├── Index.cshtml
│ └── Index.cshtml.cs
├── Persistence
│ ├── Models
│ │ ├── ConnectionRoom.cs
│ │ └── EventType.cs
│ └── Repository.cs
├── Program.cs
├── Properties
│ └── launchSettings.json
├── Services
│ ├── LiveViewService.cs
│ └── RandomRoomQueue.cs
├── TeamSketch.Web.csproj
├── Utils
│ └── RoomNameGenerator.cs
├── appsettings.Development.json
├── appsettings.json
└── wwwroot
│ ├── favicon.png
│ ├── images
│ └── marker.svg
│ ├── index.css
│ └── index.js
└── TeamSketch
├── App.axaml
├── App.axaml.cs
├── Assets
├── Fonts
│ ├── RedHatDisplay-Medium.ttf
│ └── RedHatDisplay-Regular.ttf
├── Icons.axaml
├── Images
│ └── Cursors
│ │ ├── brush-black.png
│ │ ├── brush-blue.png
│ │ ├── brush-gray.png
│ │ ├── brush-green.png
│ │ ├── brush-orange.png
│ │ ├── brush-pink.png
│ │ ├── brush-purple.png
│ │ ├── brush-red.png
│ │ ├── brush-yellow.png
│ │ ├── eraser-10.png
│ │ ├── eraser-2.png
│ │ ├── eraser-4.png
│ │ ├── eraser-50.png
│ │ ├── eraser-6.png
│ │ ├── eraser-8.png
│ │ └── eraser.bmp
└── logo.ico
├── Converters
└── EnumToBooleanConverter.cs
├── DependencyInjection
├── Bootstrapper.cs
└── ReadonlyDependencyResolverExtensions.cs
├── Globals.cs
├── Models
├── BrushSettings.cs
├── ColorsEnum.cs
└── ThicknessEnum.cs
├── Program.cs
├── Properties
└── PublishProfiles
│ └── Win64.pubxml
├── Serialization
└── SourceGenerationContext.cs
├── Services
├── AppState.cs
├── Renderer.cs
└── SignalRService.cs
├── TeamSketch.csproj
├── UserControls
├── ConnectionStatus.axaml
├── ConnectionStatus.axaml.cs
├── EventsPanel.axaml
├── EventsPanel.axaml.cs
├── ParticipantsPanel.axaml
├── ParticipantsPanel.axaml.cs
├── ToolsPanel.axaml
└── ToolsPanel.axaml.cs
├── Utils
├── HttpProxy.cs
└── PayloadConverter.cs
├── ViewModels
├── ErrorViewModel.cs
├── LobbyViewModel.cs
├── MainWindowViewModel.cs
└── UserControls
│ ├── ConnectionStatusViewModel.cs
│ ├── EventsPanelViewModel.cs
│ ├── ParticipantsPanelViewModel.cs
│ └── ToolsPanelViewModel.cs
├── Views
├── ErrorWindow.axaml
├── ErrorWindow.axaml.cs
├── LobbyWindow.axaml
├── LobbyWindow.axaml.cs
├── MainWindow.axaml
└── MainWindow.axaml.cs
└── logo.ico
/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | *.*~
3 | project.lock.json
4 | .DS_Store
5 | *.pyc
6 | nupkg/
7 |
8 | # Visual Studio Code
9 | .vscode
10 |
11 | # Rider
12 | .idea
13 |
14 | # User-specific files
15 | *.suo
16 | *.user
17 | *.userosscache
18 | *.sln.docstates
19 |
20 | # Build results
21 | [Dd]ebug/
22 | [Dd]ebugPublic/
23 | [Rr]elease/
24 | [Rr]eleases/
25 | x64/
26 | x86/
27 | build/
28 | bld/
29 | [Bb]in/
30 | [Oo]bj/
31 | [Oo]ut/
32 | msbuild.log
33 | msbuild.err
34 | msbuild.wrn
35 |
36 | # Visual Studio 2015
37 | .vs/
38 |
39 | # Custom
40 | publishing/*/*.lnk
41 | publishing/*/installers/
42 | publishing/linux/output/
43 | !publishing/linux/template_*/usr/bin
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ✍️ Team Sketch
2 |
3 | A cross-platform desktop application for collaborative drawing built with [Avalonia](https://avaloniaui.net/).
4 |
5 | 
6 |
7 | ### Features
8 |
9 | Create a room and connect with friends or join a random room and connect with a stranger. Share the canvas and show off your terrible drawing skills.
10 |
11 | ### Download
12 |
13 | You can download it from my website [here](https://www.davidtimovski.com/team-sketch#download).
14 |
--------------------------------------------------------------------------------
/TeamSketch.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 17
4 | VisualStudioVersion = 17.1.32328.378
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeamSketch", "src\TeamSketch\TeamSketch.csproj", "{903BE624-ECA2-4BB2-8300-B9C408540E1B}"
7 | EndProject
8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeamSketch.Web", "src\TeamSketch.Web\TeamSketch.Web.csproj", "{4DA7012B-0EEA-4C15-96A2-302AFA036B13}"
9 | EndProject
10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeamSketch.Benchmarks", "src\TeamSketch.Benchmarks\TeamSketch.Benchmarks.csproj", "{FF7AE278-0912-4BD9-8A81-69D9EEC9F1D5}"
11 | EndProject
12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeamSketch.Common", "src\TeamSketch.Common\TeamSketch.Common.csproj", "{0AF8DCE2-00D4-45A9-864D-44DBDF0AAFA0}"
13 | EndProject
14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8079599B-405F-4D9C-B2D2-3571FF9CD75A}"
15 | ProjectSection(SolutionItems) = preProject
16 | .gitignore = .gitignore
17 | kestrel-team-sketch.service = kestrel-team-sketch.service
18 | README.md = README.md
19 | EndProjectSection
20 | EndProject
21 | Global
22 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
23 | Debug|Any CPU = Debug|Any CPU
24 | Release|Any CPU = Release|Any CPU
25 | EndGlobalSection
26 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
27 | {903BE624-ECA2-4BB2-8300-B9C408540E1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
28 | {903BE624-ECA2-4BB2-8300-B9C408540E1B}.Debug|Any CPU.Build.0 = Debug|Any CPU
29 | {903BE624-ECA2-4BB2-8300-B9C408540E1B}.Release|Any CPU.ActiveCfg = Release|Any CPU
30 | {903BE624-ECA2-4BB2-8300-B9C408540E1B}.Release|Any CPU.Build.0 = Release|Any CPU
31 | {4DA7012B-0EEA-4C15-96A2-302AFA036B13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
32 | {4DA7012B-0EEA-4C15-96A2-302AFA036B13}.Debug|Any CPU.Build.0 = Debug|Any CPU
33 | {4DA7012B-0EEA-4C15-96A2-302AFA036B13}.Release|Any CPU.ActiveCfg = Release|Any CPU
34 | {4DA7012B-0EEA-4C15-96A2-302AFA036B13}.Release|Any CPU.Build.0 = Release|Any CPU
35 | {FF7AE278-0912-4BD9-8A81-69D9EEC9F1D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
36 | {FF7AE278-0912-4BD9-8A81-69D9EEC9F1D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
37 | {FF7AE278-0912-4BD9-8A81-69D9EEC9F1D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
38 | {FF7AE278-0912-4BD9-8A81-69D9EEC9F1D5}.Release|Any CPU.Build.0 = Release|Any CPU
39 | {0AF8DCE2-00D4-45A9-864D-44DBDF0AAFA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
40 | {0AF8DCE2-00D4-45A9-864D-44DBDF0AAFA0}.Debug|Any CPU.Build.0 = Debug|Any CPU
41 | {0AF8DCE2-00D4-45A9-864D-44DBDF0AAFA0}.Release|Any CPU.ActiveCfg = Release|Any CPU
42 | {0AF8DCE2-00D4-45A9-864D-44DBDF0AAFA0}.Release|Any CPU.Build.0 = Release|Any CPU
43 | EndGlobalSection
44 | GlobalSection(SolutionProperties) = preSolution
45 | HideSolutionNode = FALSE
46 | EndGlobalSection
47 | GlobalSection(ExtensibilityGlobals) = postSolution
48 | SolutionGuid = {2B992C9A-6E0B-4F83-A60A-DDE3F5CF1C15}
49 | EndGlobalSection
50 | EndGlobal
51 |
--------------------------------------------------------------------------------
/design/brush-colored.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
28 |
--------------------------------------------------------------------------------
/design/brush.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
14 |
--------------------------------------------------------------------------------
/design/icon.ai:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/design/icon.ai
--------------------------------------------------------------------------------
/kestrel-team-sketch.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Team Sketch running on Ubuntu
3 |
4 | [Service]
5 | WorkingDirectory=/home/davidtimovski/team-sketch
6 | ExecStart=dotnet /home/davidtimovski/team-sketch/TeamSketch.Web.dll
7 | Restart=always
8 | # Restart service after 10 seconds if the dotnet service crashes:
9 | RestartSec=10
10 | KillSignal=SIGINT
11 | SyslogIdentifier=dotnet-team-sketch
12 | User=davidtimovski
13 | Environment=ASPNETCORE_ENVIRONMENT=Production
14 | Environment=DOTNET_NOLOGO=true
15 |
16 | [Install]
17 | WantedBy=multi-user.target
18 |
--------------------------------------------------------------------------------
/preview.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/preview.webp
--------------------------------------------------------------------------------
/publishing/linux/README.md:
--------------------------------------------------------------------------------
1 | # Publishing for Linux
2 |
3 | 1. Update the version and potentially target framework in the files
4 | 2. Publish the app for x64 and ARM with the following commands:
5 | ```
6 | dotnet publish "./TeamSketch.csproj" --verbosity quiet --nologo --configuration Release --self-contained true --runtime linux-x64 --output "./bin/Release/net8.0/linux-x64"
7 | dotnet publish "./TeamSketch.csproj" --verbosity quiet --nologo --configuration Release --self-contained true --runtime linux-arm --output "./bin/Release/net8.0/linux-arm"
8 | ```
9 | 3. Create the deployment files by running the PowerShell scripts
10 | 4. Move the produced files from the `output` folder to WSL and run the Debian packaging commands:
11 |
12 | ```
13 | dpkg-deb --root-owner-group --build team-sketch_*_amd64
14 | dpkg-deb --root-owner-group --build team-sketch_*_armhf
15 | ```
16 |
17 | Follow the [official Avalonia UI guidance](https://docs.avaloniaui.net/docs/deployment/debian-ubuntu) for more details.
18 |
--------------------------------------------------------------------------------
/publishing/linux/publish_64.ps1:
--------------------------------------------------------------------------------
1 | $Version = "0.10.0"
2 | $RepoDir = "R:\repos\team-sketch"
3 | $TargetFramework = "net8.0"
4 |
5 | cd "${RepoDir}\publishing\linux"
6 |
7 | New-Item -Path . -Name "output\team-sketch_${Version}_amd64" -ItemType "directory"
8 | Copy-Item -Path "${RepoDir}\publishing\linux\template_64\*" -Destination "${RepoDir}\publishing\linux\output\team-sketch_${Version}_amd64\" -Recurse
9 |
10 | New-Item -Path . -Name "output\team-sketch_${Version}_amd64\usr\lib\team-sketch" -ItemType "directory"
11 | Copy-Item "${RepoDir}\src\TeamSketch\bin\Release\${TargetFramework}\linux-x64\*" -Destination "${RepoDir}\publishing\linux\output\team-sketch_${Version}_amd64\usr\lib\team-sketch\"
12 |
--------------------------------------------------------------------------------
/publishing/linux/publish_arm.ps1:
--------------------------------------------------------------------------------
1 | $Version = "0.10.0"
2 | $RepoDir = "R:\repos\team-sketch"
3 | $TargetFramework = "net8.0"
4 |
5 | cd "${RepoDir}\publishing\linux"
6 |
7 | New-Item -Path . -Name "output\team-sketch_${Version}_armhf" -ItemType "directory"
8 | Copy-Item -Path "${RepoDir}\publishing\linux\template_arm\*" -Destination "${RepoDir}\publishing\linux\output\team-sketch_${Version}_armhf\" -Recurse
9 |
10 | New-Item -Path . -Name "output\team-sketch_${Version}_armhf\usr\lib\team-sketch" -ItemType "directory"
11 | Copy-Item "${RepoDir}\src\TeamSketch\bin\Release\${TargetFramework}\linux-arm\*" -Destination "${RepoDir}\publishing\linux\output\team-sketch_${Version}_armhf\usr\lib\team-sketch\"
12 |
--------------------------------------------------------------------------------
/publishing/linux/template_64/DEBIAN/control:
--------------------------------------------------------------------------------
1 | Package: team-sketch
2 | Version: 0.10.0
3 | Section: devel
4 | Priority: optional
5 | Architecture: amd64
6 | Depends: libc6, libc6, libgcc-s1, libicu74, libssl3, libstdc++6, zlib1g
7 | Maintainer: David Timovski
8 | Homepage: https://www.davidtimovski.com/team-sketch
9 | Description: Sketch with your friends.
10 | Copyright: 2021-2025 David Timovski
11 |
--------------------------------------------------------------------------------
/publishing/linux/template_64/usr/bin/team-sketch:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # use exec to not have the wrapper script staying as a separate process
3 | # "$@" to pass command line arguments to the app
4 | exec /usr/lib/team-sketch/TeamSketch "$@"
--------------------------------------------------------------------------------
/publishing/linux/template_64/usr/share/applications/TeamSketch.desktop:
--------------------------------------------------------------------------------
1 | [Desktop Entry]
2 | Name=Team Sketch
3 | Comment=Sketch with your friends
4 | Icon=team-sketch
5 | Exec=team-sketch
6 | StartupWMClass=team-sketch
7 | Terminal=false
8 | Type=Application
9 | Categories=Art;
10 | GenericName=Team Sketch
11 | Keywords=team; sketch;
--------------------------------------------------------------------------------
/publishing/linux/template_64/usr/share/icons/hicolor/128x128/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_64/usr/share/icons/hicolor/128x128/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_64/usr/share/icons/hicolor/16x16/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_64/usr/share/icons/hicolor/16x16/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_64/usr/share/icons/hicolor/256x256/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_64/usr/share/icons/hicolor/256x256/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_64/usr/share/icons/hicolor/32x32/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_64/usr/share/icons/hicolor/32x32/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_64/usr/share/icons/hicolor/48x48/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_64/usr/share/icons/hicolor/48x48/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_64/usr/share/icons/hicolor/512x512/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_64/usr/share/icons/hicolor/512x512/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_64/usr/share/icons/hicolor/64x64/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_64/usr/share/icons/hicolor/64x64/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_64/usr/share/icons/hicolor/scalable/apps/team-sketch.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
99 |
--------------------------------------------------------------------------------
/publishing/linux/template_64/usr/share/pixmaps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_64/usr/share/pixmaps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_arm/DEBIAN/control:
--------------------------------------------------------------------------------
1 | Package: team-sketch
2 | Version: 0.10.0
3 | Section: devel
4 | Priority: optional
5 | Architecture: armhf
6 | Depends: libc6, libc6, libgcc-s1, libicu74, libssl3, libstdc++6, zlib1g
7 | Maintainer: David Timovski
8 | Homepage: https://www.davidtimovski.com/team-sketch
9 | Description: Sketch with your friends.
10 | Copyright: 2021-2025 David Timovski
11 |
--------------------------------------------------------------------------------
/publishing/linux/template_arm/usr/bin/team-sketch:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # use exec to not have the wrapper script staying as a separate process
3 | # "$@" to pass command line arguments to the app
4 | exec /usr/lib/team-sketch/TeamSketch "$@"
--------------------------------------------------------------------------------
/publishing/linux/template_arm/usr/share/applications/TeamSketch.desktop:
--------------------------------------------------------------------------------
1 | [Desktop Entry]
2 | Name=Team Sketch
3 | Comment=Sketch with your friends
4 | Icon=team-sketch
5 | Exec=team-sketch
6 | StartupWMClass=team-sketch
7 | Terminal=false
8 | Type=Application
9 | Categories=Art;
10 | GenericName=Team Sketch
11 | Keywords=team; sketch;
--------------------------------------------------------------------------------
/publishing/linux/template_arm/usr/share/icons/hicolor/128x128/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_arm/usr/share/icons/hicolor/128x128/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_arm/usr/share/icons/hicolor/16x16/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_arm/usr/share/icons/hicolor/16x16/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_arm/usr/share/icons/hicolor/256x256/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_arm/usr/share/icons/hicolor/256x256/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_arm/usr/share/icons/hicolor/32x32/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_arm/usr/share/icons/hicolor/32x32/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_arm/usr/share/icons/hicolor/48x48/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_arm/usr/share/icons/hicolor/48x48/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_arm/usr/share/icons/hicolor/512x512/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_arm/usr/share/icons/hicolor/512x512/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_arm/usr/share/icons/hicolor/64x64/apps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_arm/usr/share/icons/hicolor/64x64/apps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/linux/template_arm/usr/share/icons/hicolor/scalable/apps/team-sketch.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
99 |
--------------------------------------------------------------------------------
/publishing/linux/template_arm/usr/share/pixmaps/team-sketch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/publishing/linux/template_arm/usr/share/pixmaps/team-sketch.png
--------------------------------------------------------------------------------
/publishing/windows/README.md:
--------------------------------------------------------------------------------
1 | # Publishing for Windows
2 |
3 | 1. Adjust the `Properties\PublishProfiles\Win64.pubxml` file if needed
4 | 2. Publish for Win64 using Visual Studio
5 | 3. Open the `win64.iss` file with Inno Setup Compiler
6 | 4. Adjust the version or target framework values
7 | 5. Compile it
8 |
--------------------------------------------------------------------------------
/publishing/windows/win64.iss:
--------------------------------------------------------------------------------
1 | ; Script generated by the Inno Setup Script Wizard.
2 | ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
3 |
4 | #define MyAppName "Team Sketch"
5 | #define MyAppVersion "0.10.0"
6 | #define MyAppPublisher "David Timovski"
7 | #define MyAppURL "https://www.davidtimovski.com/team-sketch"
8 | #define MyAppExeName "TeamSketch.exe"
9 |
10 | [Setup]
11 | ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
12 | ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
13 | AppId={{7D96538B-CFB8-48E1-967D-050C4DDDB3E6}}
14 | AppName={#MyAppName}
15 | AppVersion={#MyAppVersion}
16 | ;AppVerName={#MyAppName} {#MyAppVersion}
17 | AppPublisher={#MyAppPublisher}
18 | AppPublisherURL={#MyAppURL}
19 | AppSupportURL={#MyAppURL}
20 | AppUpdatesURL={#MyAppURL}
21 | DefaultDirName={autopf}\{#MyAppName}
22 | DisableProgramGroupPage=yes
23 | ; Uncomment the following line to run in non administrative install mode (install for current user only.)
24 | ;PrivilegesRequired=lowest
25 | OutputDir=R:\repos\team-sketch\publishing\windows\installers
26 | OutputBaseFilename="team-sketch_{#MyAppVersion}_win64_setup"
27 | SetupIconFile=R:\repos\team-sketch\src\TeamSketch\Assets\logo.ico
28 | Compression=lzma
29 | SolidCompression=yes
30 | WizardStyle=modern
31 |
32 | [Languages]
33 | Name: "english"; MessagesFile: "compiler:Default.isl"
34 |
35 | [Tasks]
36 | Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
37 |
38 | [Files]
39 | Source: "R:\repos\team-sketch\src\TeamSketch\bin\Release\net8.0\publish\win-x64\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
40 | Source: "R:\repos\team-sketch\src\TeamSketch\bin\Release\net8.0\publish\win-x64\*.dll"; DestDir: "{app}"; Flags: ignoreversion
41 | ; NOTE: Don't use "Flags: ignoreversion" on any shared system files
42 |
43 | [Icons]
44 | Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
45 | Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
46 |
47 | [Run]
48 | Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
49 |
--------------------------------------------------------------------------------
/sql/connections.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE public.connections
2 | (
3 | id serial NOT NULL,
4 | room_id integer NOT NULL,
5 | signalr_connection_id character varying(30) NOT NULL COLLATE pg_catalog."default",
6 | ip_address character varying(15) COLLATE pg_catalog."default",
7 | "user" character varying(30) NOT NULL COLLATE pg_catalog."default",
8 | is_connected boolean NOT NULL DEFAULT TRUE,
9 | created timestamp with time zone NOT NULL,
10 | modified timestamp with time zone NOT NULL,
11 | CONSTRAINT "PK_connections" PRIMARY KEY (id),
12 | CONSTRAINT "FK_connections_rooms_room_id" FOREIGN KEY (room_id)
13 | REFERENCES public.rooms (id) MATCH SIMPLE
14 | ON UPDATE NO ACTION
15 | ON DELETE CASCADE
16 | DEFERRABLE INITIALLY DEFERRED
17 | )
18 | WITH (
19 | OIDS = FALSE
20 | )
21 | TABLESPACE pg_default;
22 |
23 | ALTER TABLE public.rooms
24 | OWNER to postgres;
25 |
26 | CREATE INDEX "IX_connections_signalr_connection_id"
27 | ON public.connections USING btree
28 | (signalr_connection_id)
29 | TABLESPACE pg_default;
30 |
--------------------------------------------------------------------------------
/sql/events.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE public.events
2 | (
3 | id serial NOT NULL,
4 | room_id integer NOT NULL,
5 | connection_id integer NOT NULL,
6 | type smallint NOT NULL,
7 | occurred timestamp with time zone NOT NULL,
8 | CONSTRAINT "PK_events" PRIMARY KEY (id),
9 | CONSTRAINT "FK_events_rooms_room_id" FOREIGN KEY (room_id)
10 | REFERENCES public.rooms (id) MATCH SIMPLE
11 | ON UPDATE NO ACTION
12 | ON DELETE CASCADE
13 | DEFERRABLE INITIALLY DEFERRED,
14 | CONSTRAINT "FK_events_connections_connection_id" FOREIGN KEY (connection_id)
15 | REFERENCES public.connections (id) MATCH SIMPLE
16 | ON UPDATE NO ACTION
17 | ON DELETE CASCADE
18 | DEFERRABLE INITIALLY DEFERRED
19 | )
20 | WITH (
21 | OIDS = FALSE
22 | )
23 | TABLESPACE pg_default;
24 |
25 | ALTER TABLE public.rooms
26 | OWNER to postgres;
27 |
--------------------------------------------------------------------------------
/sql/rooms.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE public.rooms
2 | (
3 | id serial NOT NULL,
4 | name character varying(7) NOT NULL COLLATE pg_catalog."default",
5 | is_public boolean NOT NULL,
6 | created timestamp with time zone NOT NULL,
7 | CONSTRAINT "PK_rooms" PRIMARY KEY (id)
8 | )
9 | WITH (
10 | OIDS = FALSE
11 | )
12 | TABLESPACE pg_default;
13 |
14 | ALTER TABLE public.rooms
15 | OWNER to postgres;
16 |
--------------------------------------------------------------------------------
/src/TeamSketch.Benchmarks/Benchmarks/PayloadConverterBenchmarks.cs:
--------------------------------------------------------------------------------
1 | using Avalonia;
2 | using BenchmarkDotNet.Attributes;
3 | using TeamSketch.Models;
4 | using TeamSketch.Utils;
5 |
6 | namespace TeamSketch.Benchmarks;
7 |
8 | [SimpleJob(launchCount: 5, warmupCount: 5)]
9 | [MemoryDiagnoser]
10 | public class PayloadConverterPointBenchmarks
11 | {
12 | private readonly Random _random = new();
13 | private readonly int x1;
14 | private readonly int y1;
15 |
16 | public PayloadConverterPointBenchmarks()
17 | {
18 | x1 = _random.Next(0, Globals.CanvasWidth);
19 | y1 = _random.Next(0, Globals.CanvasHeight);
20 | }
21 |
22 | [Benchmark]
23 | public void PointToBytes() => PayloadConverter.ToBytes(x1, y1, ThicknessEnum.SemiThin, ColorsEnum.Blue);
24 | }
25 |
26 | [SimpleJob(launchCount: 5, warmupCount: 5)]
27 | [MemoryDiagnoser]
28 | public class PayloadConverterLineBenchmarks
29 | {
30 | private readonly Random _random = new();
31 | private readonly List _linePoints = new(40);
32 |
33 | public PayloadConverterLineBenchmarks()
34 | {
35 | for (int i = 0; i < _linePoints.Count; i++)
36 | {
37 | var x = _random.Next(0, Globals.CanvasWidth);
38 | var y = _random.Next(0, Globals.CanvasHeight);
39 | _linePoints[i] = new Point(x, y);
40 | }
41 | }
42 |
43 | [Benchmark]
44 | public void LineToBytes() => PayloadConverter.ToBytes(_linePoints, ThicknessEnum.SemiThin, ColorsEnum.Blue);
45 | }
46 |
--------------------------------------------------------------------------------
/src/TeamSketch.Benchmarks/Benchmarks/RendererBenchmarks.cs:
--------------------------------------------------------------------------------
1 | using Avalonia;
2 | using Avalonia.Controls;
3 | using BenchmarkDotNet.Attributes;
4 | using TeamSketch.Models;
5 | using TeamSketch.Services;
6 |
7 | namespace TeamSketch.Benchmarks;
8 |
9 | [SimpleJob(launchCount: 5, warmupCount: 5)]
10 | [MemoryDiagnoser]
11 | public class RendererBenchmarks
12 | {
13 | private readonly Random _random = new();
14 | private readonly IRenderer _renderer;
15 | private const int LineSegmentsCount = 20;
16 |
17 | public RendererBenchmarks()
18 | {
19 | var canvas = new Canvas
20 | {
21 | Width = Globals.CanvasWidth,
22 | Height = Globals.CanvasHeight
23 | };
24 | _renderer = new Renderer(new BrushSettings(""), canvas);
25 |
26 | for (int i = 0; i < LineSegmentsCount; i++)
27 | {
28 | var x1 = _random.Next(0, Globals.CanvasWidth);
29 | var y1 = _random.Next(0, Globals.CanvasHeight);
30 | var x2 = _random.Next(0, Globals.CanvasWidth);
31 | var y2 = _random.Next(0, Globals.CanvasHeight);
32 | _renderer.EnqueueLineSegment(new Point(x1, y1), new Point(x2, y2));
33 | }
34 | }
35 |
36 | [Benchmark]
37 | public void RenderLine() => _renderer.RenderLine();
38 | }
39 |
--------------------------------------------------------------------------------
/src/TeamSketch.Benchmarks/Program.cs:
--------------------------------------------------------------------------------
1 | using BenchmarkDotNet.Running;
2 |
3 | var summary = BenchmarkRunner.Run(typeof(Program).Assembly);
4 |
--------------------------------------------------------------------------------
/src/TeamSketch.Benchmarks/TeamSketch.Benchmarks.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net8.0
6 | enable
7 | enable
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/TeamSketch.Common/ApiModels/JoinRoomValidationResult.cs:
--------------------------------------------------------------------------------
1 | namespace TeamSketch.Common.ApiModels;
2 |
3 | public readonly record struct JoinRoomValidationResult(bool RoomExists = true, bool RoomIsFull = false, bool NicknameIsTaken = false);
4 |
--------------------------------------------------------------------------------
/src/TeamSketch.Common/TeamSketch.Common.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/TeamSketch.Common/Validations.cs:
--------------------------------------------------------------------------------
1 | using System.Text.RegularExpressions;
2 |
3 | namespace TeamSketch.Common;
4 |
5 | public static partial class Validations
6 | {
7 | public static string? ValidateNickname(string nickname)
8 | {
9 | if (!IsAlphanumeric(nickname))
10 | {
11 | return "Nicknames can contain only letters, numbers, and spaces.";
12 | }
13 |
14 | if (nickname.Trim().Length < 2)
15 | {
16 | return "Nicknames can be from 2 to 30 characters long.";
17 | }
18 |
19 | return null;
20 | }
21 |
22 | public static string? ValidateRoomName(string roomName)
23 | {
24 | if (!IsAlphanumeric(roomName))
25 | {
26 | return "Room names only contain letters and numbers.";
27 | }
28 |
29 | if (roomName.Trim().Length != 7)
30 | {
31 | return "Room names are 7 characters long.";
32 | }
33 |
34 | return null;
35 | }
36 |
37 | private static bool IsAlphanumeric(string text)
38 | {
39 | if (text == null)
40 | {
41 | throw new ArgumentNullException(nameof(text));
42 | }
43 |
44 | text = text.Trim();
45 |
46 | return AlphanumericRegex().IsMatch(text);
47 | }
48 |
49 | [GeneratedRegex(@"^[a-zA-Z0-9\s]*$")]
50 | private static partial Regex AlphanumericRegex();
51 | }
52 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Config/DatabaseSettings.cs:
--------------------------------------------------------------------------------
1 | namespace TeamSketch.Web.Config;
2 |
3 | public sealed class DatabaseSettings
4 | {
5 | public required string ConnectionString { get; init; }
6 | }
7 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Controllers/LiveViewController.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Mvc;
2 | using TeamSketch.Web.Services;
3 |
4 | namespace TeamSketch.Web.Controllers;
5 |
6 | [Route("api/[controller]")]
7 | [ApiController]
8 | public sealed class LiveViewController : ControllerBase
9 | {
10 | private readonly ILiveViewService _liveLiveService;
11 |
12 | public LiveViewController(ILiveViewService liveViewService)
13 | {
14 | _liveLiveService = liveViewService;
15 | }
16 |
17 | [HttpGet]
18 | public IActionResult Get()
19 | {
20 | var locations = _liveLiveService.GetDistinctLocations();
21 | return Ok(locations);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Controllers/RoomsController.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Mvc;
2 | using TeamSketch.Common.ApiModels;
3 | using TeamSketch.Web.Persistence;
4 |
5 | namespace TeamSketch.Web.Controllers;
6 |
7 | [Route("api/[controller]")]
8 | [ApiController]
9 | public sealed class RoomsController : ControllerBase
10 | {
11 | private readonly IRepository _repository;
12 |
13 | public RoomsController(IRepository repository)
14 | {
15 | _repository = repository;
16 | }
17 |
18 | [HttpGet("{room}/validate-join/{nickname}")]
19 | public async Task ValidateJoin(string room, string nickname)
20 | {
21 | if (string.IsNullOrEmpty(room) || string.IsNullOrEmpty(nickname))
22 | {
23 | return BadRequest();
24 | }
25 |
26 | var exists = await _repository.RoomExistsAsync(room);
27 | if (!exists)
28 | {
29 | return Ok(new JoinRoomValidationResult { RoomExists = false });
30 | }
31 |
32 | var participantsInRoom = await _repository.GetActiveParticipantsInRoomAsync(room);
33 | if (participantsInRoom.Count > 4)
34 | {
35 | return Ok(new JoinRoomValidationResult { RoomExists = true, RoomIsFull = true });
36 | }
37 |
38 | if (participantsInRoom.Contains(nickname))
39 | {
40 | return Ok(new JoinRoomValidationResult { RoomExists = true, RoomIsFull = false, NicknameIsTaken = true });
41 | }
42 |
43 | return Ok(new JoinRoomValidationResult { RoomExists = true });
44 | }
45 |
46 | [HttpGet("{room}/participants")]
47 | public async Task GetParticipantsInRoom(string room)
48 | {
49 | if (string.IsNullOrEmpty(room))
50 | {
51 | return BadRequest();
52 | }
53 |
54 | var participantsInRoom = await _repository.GetActiveParticipantsInRoomAsync(room);
55 | return Ok(participantsInRoom);
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Hubs/ActionHub.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.SignalR;
2 | using TeamSketch.Common;
3 | using TeamSketch.Web.Persistence;
4 | using TeamSketch.Web.Services;
5 | using TeamSketch.Web.Utils;
6 |
7 | namespace TeamSketch.Web.Hubs;
8 |
9 | public sealed class ActionHub : Hub
10 | {
11 | private readonly IRepository _repository;
12 | private readonly IRandomRoomQueue _randomRoomQueue;
13 | private readonly ILiveViewService _liveViewService;
14 |
15 | public ActionHub(IRepository repository, IRandomRoomQueue randomRoomQueue, ILiveViewService liveViewService)
16 | {
17 | _repository = repository;
18 | _randomRoomQueue = randomRoomQueue;
19 | _liveViewService = liveViewService;
20 | }
21 |
22 | public async Task CreateRoom(string nickname)
23 | {
24 | var nicknameError = Validations.ValidateNickname(nickname);
25 | if (nicknameError != null)
26 | {
27 | throw new InvalidOperationException(nicknameError);
28 | }
29 |
30 | string room = RoomNameGenerator.Generate();
31 |
32 | await Groups.AddToGroupAsync(Context.ConnectionId, room);
33 | await _repository.CreateRoomAsync(room, false, nickname, Context.ConnectionId, GetIpAddress());
34 |
35 | await Clients.Caller.SendAsync("RoomCreated", room);
36 | }
37 |
38 | public async Task JoinRoom(string nickname, string room)
39 | {
40 | var nicknameError = Validations.ValidateNickname(nickname);
41 | if (nicknameError != null)
42 | {
43 | throw new InvalidOperationException(nicknameError);
44 | }
45 |
46 | var exists = await _repository.RoomExistsAsync(room);
47 | if (!exists)
48 | {
49 | throw new InvalidOperationException($"Room '{room}' does not exist.");
50 | }
51 |
52 | var participantsInRoom = await _repository.GetActiveParticipantsInRoomAsync(room);
53 | if (participantsInRoom.Count > 4)
54 | {
55 | throw new InvalidOperationException($"Room '{room}' is currently full.");
56 | }
57 |
58 | if (participantsInRoom.Contains(nickname))
59 | {
60 | throw new InvalidOperationException($"Nickname '{nickname}' is taken in room '{room}'.");
61 | }
62 |
63 | await Groups.AddToGroupAsync(Context.ConnectionId, room);
64 | await _repository.JoinRoomAsync(room, nickname, Context.ConnectionId, GetIpAddress());
65 |
66 | await Clients.OthersInGroup(room).SendAsync("JoinedRoom", nickname);
67 | }
68 |
69 | public async Task JoinRandomRoom(string nickname)
70 | {
71 | UserInQueue? userInQueue = _randomRoomQueue.Dequeue();
72 | if (userInQueue == null)
73 | {
74 | _randomRoomQueue.Enqueue(Context.ConnectionId, nickname, GetIpAddress());
75 | return;
76 | }
77 |
78 | string room = RoomNameGenerator.Generate();
79 |
80 | await Groups.AddToGroupAsync(userInQueue.ConnectionId, room);
81 | await Groups.AddToGroupAsync(Context.ConnectionId, room);
82 |
83 | await _repository.CreateRoomAsync(room, true, userInQueue.Nickname, userInQueue.ConnectionId, userInQueue.IpAddress);
84 |
85 | await _repository.JoinRoomAsync(room, userInQueue.Nickname, userInQueue.ConnectionId, userInQueue.IpAddress);
86 | var ipAddress = GetIpAddress();
87 | await _repository.JoinRoomAsync(room, nickname, Context.ConnectionId, ipAddress);
88 |
89 | await Clients.Group(room).SendAsync("RandomRoomJoined", room);
90 |
91 | await _liveViewService.AddAsync(userInQueue.ConnectionId, userInQueue.IpAddress);
92 | await _liveViewService.AddAsync(Context.ConnectionId, ipAddress);
93 | }
94 |
95 | public async Task DrawPoint(string nickname, string room, byte[] data)
96 | {
97 | await Clients.OthersInGroup(room).SendAsync("DrewPoint", nickname, data);
98 | }
99 |
100 | public async Task DrawLine(string nickname, string room, byte[] data)
101 | {
102 | await Clients.OthersInGroup(room).SendAsync("DrewLine", nickname, data);
103 | }
104 |
105 | public Task Ping()
106 | {
107 | return Clients.Caller.SendAsync("Pong");
108 | }
109 |
110 | public override async Task OnDisconnectedAsync(Exception? exception)
111 | {
112 | _randomRoomQueue.Remove(Context.ConnectionId);
113 |
114 | var connectionRoom = await _repository.DisconnectAsync(Context.ConnectionId);
115 | if (connectionRoom != null)
116 | {
117 | await Clients.OthersInGroup(connectionRoom.Room).SendAsync("LeftRoom", connectionRoom.Nickname);
118 | }
119 |
120 | _liveViewService.Remove(Context.ConnectionId);
121 |
122 | await base.OnDisconnectedAsync(exception);
123 | }
124 |
125 | private string? GetIpAddress()
126 | {
127 | var httpContext = Context.GetHttpContext();
128 |
129 | return httpContext?.Request.Headers["X-Forwarded-For"];
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Pages/Index.cshtml:
--------------------------------------------------------------------------------
1 | @page
2 | @model TeamSketch.Web.Pages.IndexModel
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Team Sketch - Live View
11 |
12 |
13 |
14 |
15 |
16 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Pages/Index.cshtml.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Mvc.RazorPages;
2 |
3 | namespace TeamSketch.Web.Pages
4 | {
5 | public class IndexModel(IConfiguration configuration) : PageModel
6 | {
7 | public string BaseUrl { get; private set; } = configuration["BaseUrl"]!;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Persistence/Models/ConnectionRoom.cs:
--------------------------------------------------------------------------------
1 | namespace TeamSketch.Web.Persistence.Models;
2 |
3 | public sealed class ConnectionRoom
4 | {
5 | public required int ConnectionId { get; init; }
6 | public required string Nickname { get; init; }
7 | public required int RoomId { get; init; }
8 | public required string Room { get; init; }
9 | }
10 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Persistence/Models/EventType.cs:
--------------------------------------------------------------------------------
1 | namespace TeamSketch.Web.Persistence;
2 |
3 | public enum EventType
4 | {
5 | Joined,
6 | Disconnected
7 | }
8 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Persistence/Repository.cs:
--------------------------------------------------------------------------------
1 | using System.Data;
2 | using Dapper;
3 | using Microsoft.Extensions.Options;
4 | using Npgsql;
5 | using TeamSketch.Web.Config;
6 | using TeamSketch.Web.Persistence.Models;
7 |
8 | namespace TeamSketch.Web.Persistence;
9 |
10 | public interface IRepository
11 | {
12 | Task RoomExistsAsync(string room);
13 | Task> GetActiveParticipantsInRoomAsync(string room);
14 | Task CreateRoomAsync(string room, bool isPublic, string nickname, string signalRConnectionId, string? ipAddress);
15 | Task JoinRoomAsync(string room, string nickname, string signalRConnectionId, string? ipAddress);
16 | Task DisconnectAsync(string signalRConnectionId);
17 | Task DisconnectAllAsync();
18 | }
19 |
20 | public sealed class Repository(IOptions databaseSettings) : IRepository
21 | {
22 | private readonly string _connectionString = databaseSettings.Value.ConnectionString;
23 |
24 | public async Task RoomExistsAsync(string room)
25 | {
26 | using IDbConnection conn = OpenConnection();
27 |
28 | return await conn.ExecuteScalarAsync(@"SELECT COUNT(*) FROM rooms WHERE name = @room", new { room });
29 | }
30 |
31 | public async Task> GetActiveParticipantsInRoomAsync(string room)
32 | {
33 | using IDbConnection conn = OpenConnection();
34 |
35 | return (await conn.QueryAsync(@"SELECT c.""user""
36 | FROM rooms AS r
37 | INNER JOIN connections AS c ON r.id = c.room_id AND c.is_connected
38 | WHERE r.name = @room", new { room })).ToList();
39 | }
40 |
41 | public async Task CreateRoomAsync(string room, bool isPublic, string nickname, string signalRConnectionId, string? ipAddress)
42 | {
43 | var now = DateTime.UtcNow;
44 |
45 | using IDbConnection conn = OpenConnection();
46 | using var transaction = conn.BeginTransaction();
47 |
48 | await conn.ExecuteAsync(@"SET CONSTRAINTS ""FK_connections_rooms_room_id"", ""FK_events_rooms_room_id"", ""FK_events_connections_connection_id"" DEFERRED", null, transaction);
49 |
50 | var roomId = await conn.ExecuteScalarAsync("INSERT INTO rooms (name, is_public, created) VALUES (@name, @isPublic, @created) RETURNING id",
51 | new { name = room, isPublic, created = now }, transaction);
52 |
53 | var connectionId = await conn.ExecuteScalarAsync(@"INSERT INTO connections (room_id, signalr_connection_id, ip_address, ""user"", is_connected, created, modified)
54 | VALUES (@roomId, @signalRConnectionId, @ipAddress, @nickname, TRUE, @created, @modified) RETURNING id",
55 | new { roomId, signalRConnectionId, ipAddress, nickname, created = now, modified = now }, transaction);
56 |
57 | await conn.ExecuteAsync("INSERT INTO events (room_id, connection_id, type, occurred) VALUES (@roomId, @connectionId, @type, @occurred)",
58 | new { roomId, connectionId, type = EventType.Joined, occurred = now }, transaction);
59 |
60 | transaction.Commit();
61 | }
62 |
63 | public async Task JoinRoomAsync(string room, string nickname, string signalRConnectionId, string? ipAddress)
64 | {
65 | var now = DateTime.UtcNow;
66 |
67 | using IDbConnection conn = OpenConnection();
68 | using var transaction = conn.BeginTransaction();
69 |
70 | await conn.ExecuteAsync(@"SET CONSTRAINTS ""FK_connections_rooms_room_id"", ""FK_events_rooms_room_id"", ""FK_events_connections_connection_id"" DEFERRED", null, transaction);
71 |
72 | var roomId = await conn.QueryFirstAsync("SELECT id FROM rooms WHERE name = @name", new { name = room });
73 |
74 | var connectionId = await conn.QueryFirstOrDefaultAsync(@"SELECT id FROM connections WHERE signalr_connection_id = @signalRConnectionId", new { signalRConnectionId });
75 | if (connectionId.HasValue)
76 | {
77 | await conn.ExecuteScalarAsync(@"UPDATE connections SET is_connected = TRUE, modified = @modified WHERE id = @connectionId", new { connectionId, modified = now }, transaction);
78 | }
79 | else
80 | {
81 | connectionId = await conn.ExecuteScalarAsync(@"INSERT INTO connections (room_id, signalr_connection_id, ip_address, ""user"", is_connected, created, modified)
82 | VALUES (@roomId, @signalRConnectionId, @ipAddress, @nickname, TRUE, @created, @modified) RETURNING id",
83 | new { roomId, signalRConnectionId, ipAddress, nickname, created = now, modified = now }, transaction);
84 | }
85 |
86 | await conn.ExecuteAsync("INSERT INTO events (room_id, connection_id, type, occurred) VALUES (@roomId, @connectionId, @type, @occurred)",
87 | new { roomId, connectionId, type = EventType.Joined, occurred = now }, transaction);
88 |
89 | transaction.Commit();
90 | }
91 |
92 | public async Task DisconnectAsync(string signalRConnectionId)
93 | {
94 | var now = DateTime.UtcNow;
95 |
96 | using IDbConnection conn = OpenConnection();
97 | using var transaction = conn.BeginTransaction();
98 |
99 | await conn.ExecuteAsync(@"SET CONSTRAINTS ""FK_events_rooms_room_id"", ""FK_events_connections_connection_id"" DEFERRED", null, transaction);
100 |
101 | var connectionRoom = await conn.QueryFirstOrDefaultAsync(@"SELECT c.id AS ""ConnectionId"", c.""user"", r.id AS ""RoomId"", r.name AS ""Room""
102 | FROM connections AS c
103 | INNER JOIN rooms AS r ON c.room_id = r.id
104 | WHERE signalr_connection_id = @signalRConnectionId", new { signalRConnectionId });
105 |
106 | if (connectionRoom != null)
107 | {
108 | await conn.ExecuteScalarAsync(@"UPDATE connections SET is_connected = FALSE, modified = @modified WHERE id = @connectionId", new { connectionId = connectionRoom.ConnectionId, modified = now }, transaction);
109 |
110 | await conn.ExecuteAsync("INSERT INTO events (room_id, connection_id, type, occurred) VALUES (@roomId, @connectionId, @type, @occurred)",
111 | new { roomId = connectionRoom.RoomId, connectionId = connectionRoom.ConnectionId, type = EventType.Disconnected, occurred = now }, transaction);
112 | }
113 |
114 | transaction.Commit();
115 |
116 | return connectionRoom;
117 | }
118 |
119 | public async Task DisconnectAllAsync()
120 | {
121 | using IDbConnection conn = OpenConnection();
122 | await conn.ExecuteScalarAsync(@"UPDATE connections SET is_connected = FALSE, modified = @modified", new { modified = DateTime.UtcNow });
123 | }
124 |
125 | private NpgsqlConnection OpenConnection()
126 | {
127 | var conn = new NpgsqlConnection(_connectionString);
128 | conn.Open();
129 |
130 | return conn;
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Program.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Http.Connections;
2 | using Microsoft.AspNetCore.HttpOverrides;
3 | using TeamSketch.Web.Config;
4 | using TeamSketch.Web.Hubs;
5 | using TeamSketch.Web.Persistence;
6 | using TeamSketch.Web.Services;
7 |
8 | var builder = WebApplication.CreateBuilder(args);
9 |
10 | builder.Services.AddControllers();
11 | builder.Services.AddRazorPages();
12 | builder.Services.AddSignalR().AddMessagePackProtocol();
13 | builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Database"));
14 |
15 | builder.Services.AddSingleton();
16 | builder.Services.AddSingleton();
17 | builder.Services.AddTransient();
18 |
19 | builder.WebHost.UseUrls("http://localhost:5150");
20 |
21 | var app = builder.Build();
22 |
23 | app.UseStaticFiles();
24 |
25 | app.UseRouting();
26 | app.MapControllers();
27 | app.UseForwardedHeaders(new ForwardedHeadersOptions
28 | {
29 | ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
30 | });
31 |
32 | app.MapRazorPages();
33 |
34 | app.MapHub("/actionHub", options =>
35 | {
36 | options.Transports = HttpTransportType.WebSockets;
37 | });
38 |
39 | app.Lifetime.ApplicationStarted.Register(async () =>
40 | {
41 | var repository = app.Services.GetRequiredService();
42 | await repository.DisconnectAllAsync();
43 | });
44 |
45 | app.Run();
46 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/launchsettings.json",
3 | "profiles": {
4 | "TeamSketch.Web": {
5 | "commandName": "Project",
6 | "dotnetRunMessages": true,
7 | "applicationUrl": "http://localhost:5206",
8 | "environmentVariables": {
9 | "ASPNETCORE_ENVIRONMENT": "Development"
10 | }
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Services/LiveViewService.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Concurrent;
2 | using System.Net;
3 | using System.Text.Json;
4 |
5 | namespace TeamSketch.Web.Services;
6 |
7 | public interface ILiveViewService
8 | {
9 | Task AddAsync(string connectionId, string? ipAddress);
10 | void Remove(string connectionId);
11 | List GetDistinctLocations();
12 | }
13 |
14 | public sealed class LiveViewService : ILiveViewService
15 | {
16 | private static readonly HttpClient HttpClient = new();
17 | private static readonly JsonSerializerOptions SerializerSettings = new()
18 | {
19 | PropertyNameCaseInsensitive = true
20 | };
21 | private readonly ConcurrentDictionary _locations = new();
22 |
23 | public LiveViewService()
24 | {
25 | HttpClient.DefaultRequestHeaders.Add(HttpRequestHeader.Accept.ToString(), "application/json");
26 | }
27 |
28 | public async Task AddAsync(string connectionId, string? ipAddress)
29 | {
30 | if (ipAddress is null)
31 | {
32 | return;
33 | }
34 |
35 | var response = await HttpClient.GetAsync(new Uri($"http://ip-api.com/json/{ipAddress}?fields=country,city,lat,lon"));
36 | if (!response.IsSuccessStatusCode)
37 | {
38 | return;
39 | }
40 |
41 | var content = await response.Content.ReadAsStringAsync();
42 | var data = JsonSerializer.Deserialize(content, SerializerSettings);
43 | if (data is null)
44 | {
45 | return;
46 | }
47 |
48 | _locations.TryAdd(connectionId, data);
49 | }
50 |
51 | public void Remove(string connectionId)
52 | {
53 | _locations.TryRemove(connectionId, out Location? _);
54 | }
55 |
56 | public List GetDistinctLocations()
57 | {
58 | return _locations.Values.Distinct().ToList();
59 | }
60 | }
61 |
62 | public record Location(string Country, string City, double Lat, double Lon);
63 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Services/RandomRoomQueue.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Concurrent;
2 |
3 | namespace TeamSketch.Web.Services;
4 |
5 | public interface IRandomRoomQueue
6 | {
7 | void Enqueue(string connectionId, string nickname, string? ipAddress);
8 | UserInQueue? Dequeue();
9 | void Remove(string connectionId);
10 | }
11 |
12 | public sealed class RandomRoomQueue : IRandomRoomQueue
13 | {
14 | private readonly ConcurrentDictionary _queue = new();
15 |
16 | public void Enqueue(string connectionId, string nickname, string? ipAddress)
17 | {
18 | _queue.TryAdd(connectionId, new UserInQueue(connectionId, nickname, ipAddress));
19 | }
20 |
21 | public UserInQueue? Dequeue()
22 | {
23 | if (_queue.IsEmpty)
24 | {
25 | return null;
26 | }
27 |
28 | _queue.Remove(_queue.First().Key, out UserInQueue? userInQueue);
29 |
30 | return userInQueue;
31 | }
32 |
33 | public void Remove(string connectionId)
34 | {
35 | _queue.TryRemove(connectionId, out UserInQueue? _);
36 | }
37 | }
38 |
39 | public record UserInQueue(string ConnectionId, string Nickname, string? IpAddress);
40 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/TeamSketch.Web.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/Utils/RoomNameGenerator.cs:
--------------------------------------------------------------------------------
1 | using System.Security.Cryptography;
2 | using System.Text;
3 |
4 | namespace TeamSketch.Web.Utils;
5 |
6 | public static class RoomNameGenerator
7 | {
8 | private static readonly char[] Chars = "abcdefghkmnprstuvwxyz123456789".ToCharArray();
9 | private const int Length = 7;
10 |
11 | public static string Generate()
12 | {
13 | var data = new byte[4 * Length];
14 | using (var crypto = RandomNumberGenerator.Create())
15 | {
16 | crypto.GetBytes(data);
17 | }
18 |
19 | var result = new StringBuilder(Length);
20 | for (int i = 0; i < Length; i++)
21 | {
22 | var random = BitConverter.ToUInt32(data, i * 4);
23 | var index = random % Chars.Length;
24 |
25 | result.Append(Chars[index]);
26 | }
27 |
28 | return result.ToString();
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/appsettings.Development.json:
--------------------------------------------------------------------------------
1 | {
2 | "Database": {
3 | "ConnectionString": "User ID=postgres;Password=postgres;Host=localhost;Port=5432;Database=teamsketch;Pooling=true;Include Error Detail=true"
4 | },
5 | "BaseUrl": "http://localhost:5150",
6 | "Logging": {
7 | "LogLevel": {
8 | "Default": "Information",
9 | "Microsoft.AspNetCore": "Information"
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "BaseUrl": "https://team-sketch.davidtimovski.com",
3 | "Logging": {
4 | "LogLevel": {
5 | "Default": "Information",
6 | "Microsoft.AspNetCore": "Information"
7 | }
8 | },
9 | "AllowedHosts": "*"
10 | }
11 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/wwwroot/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch.Web/wwwroot/favicon.png
--------------------------------------------------------------------------------
/src/TeamSketch.Web/wwwroot/images/marker.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/wwwroot/index.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | padding: 0;
3 | margin: 0;
4 | }
5 |
--------------------------------------------------------------------------------
/src/TeamSketch.Web/wwwroot/index.js:
--------------------------------------------------------------------------------
1 | const centerOfWorld = ol.proj.fromLonLat([40.86666, 34.56666]);
2 | const map = new ol.Map({
3 | target: 'map',
4 | layers: [
5 | new ol.layer.Tile({
6 | source: new ol.source.OSM()
7 | })
8 | ],
9 | view: new ol.View({
10 | center: centerOfWorld,
11 | zoom: 3
12 | })
13 | });
14 |
15 | let iconLayer;
16 |
17 | loadLocationMarkers();
18 | setInterval(() => {
19 | loadLocationMarkers();
20 | }, 5000);
21 |
22 | function loadLocationMarkers() {
23 | fetch(`${baseUrl}/api/liveview`, {
24 | headers: {
25 | 'Content-Type': 'application/json'
26 | }
27 | })
28 | .then(response => response.json())
29 | .then(addLocationMarkers);
30 | }
31 |
32 | function addLocationMarkers(locations) {
33 | const features = [];
34 |
35 | for (let location of locations) {
36 | const feature = new ol.Feature({
37 | geometry: new ol.geom.Point(ol.proj.fromLonLat([location.lon, location.lat]))
38 | });
39 |
40 | const style = getMarkerStyle(`${location.city}, ${location.country}`);
41 | feature.setStyle(style);
42 |
43 | features.push(feature);
44 | }
45 |
46 | if (iconLayer) {
47 | map.removeLayer(iconLayer);
48 | }
49 |
50 | iconLayer = new ol.layer.Vector({
51 | source: new ol.source.Vector({
52 | features: features
53 | })
54 | });
55 |
56 | map.addLayer(iconLayer);
57 | }
58 |
59 | function getMarkerStyle(label) {
60 | return new ol.style.Style({
61 | image: new ol.style.Icon({
62 | anchor: [0.5, 42],
63 | anchorXUnits: 'fraction',
64 | anchorYUnits: 'pixels',
65 | src: 'images/marker.svg'
66 | }),
67 | text: new ol.style.Text({
68 | font: "12px sans-serif",
69 | fill: new ol.style.Fill({ color: '#343434' }),
70 | stroke: new ol.style.Stroke({
71 | color: '#fff',
72 | width: 2
73 | }),
74 | backgroundFill: new ol.style.Fill({ color: "#fffffa" }),
75 | backgroundStroke: new ol.style.Stroke({
76 | color: '#2181ff',
77 | width: 1
78 | }),
79 | padding: [3, 7, 1, 7],
80 | offsetY: 18,
81 | text: label
82 | })
83 | })
84 | }
85 |
--------------------------------------------------------------------------------
/src/TeamSketch/App.axaml:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 | resm:TeamSketch.Assets.Fonts?assembly=TeamSketch#Red Hat Display
8 | #2181ff
9 | #3191ff
10 |
11 | #eb3324
12 | #00a2e8
13 | #22b14c
14 | #fff200
15 | #ff7f27
16 | #a349a4
17 | #ffaec9
18 | #c3c3c3
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
31 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/TeamSketch/App.axaml.cs:
--------------------------------------------------------------------------------
1 | using Avalonia;
2 | using Avalonia.Controls.ApplicationLifetimes;
3 | using Avalonia.Markup.Xaml;
4 | using TeamSketch.ViewModels;
5 | using TeamSketch.Views;
6 |
7 | namespace TeamSketch;
8 |
9 | public class App : Application
10 | {
11 | public override void Initialize()
12 | {
13 | AvaloniaXamlLoader.Load(this);
14 | }
15 |
16 | public override void OnFrameworkInitializationCompleted()
17 | {
18 | if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
19 | {
20 | desktopLifetime.Startup += Startup;
21 |
22 | var window = new LobbyWindow
23 | {
24 | DataContext = new LobbyViewModel(),
25 | Topmost = true,
26 | CanResize = false
27 | };
28 | window.Show();
29 | window.Activate();
30 | }
31 |
32 | base.OnFrameworkInitializationCompleted();
33 | }
34 |
35 | private static void Startup(object _, ControlledApplicationLifetimeStartupEventArgs e)
36 | {
37 | if (e.Args.Length > 0)
38 | {
39 | Globals.RenderingIntervalMs = short.Parse(e.Args[0]);
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Fonts/RedHatDisplay-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Fonts/RedHatDisplay-Medium.ttf
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Fonts/RedHatDisplay-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Fonts/RedHatDisplay-Regular.ttf
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Icons.axaml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
11 |
12 |
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/brush-black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/brush-black.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/brush-blue.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/brush-blue.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/brush-gray.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/brush-gray.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/brush-green.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/brush-green.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/brush-orange.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/brush-orange.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/brush-pink.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/brush-pink.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/brush-purple.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/brush-purple.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/brush-red.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/brush-red.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/brush-yellow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/brush-yellow.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/eraser-10.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/eraser-10.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/eraser-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/eraser-2.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/eraser-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/eraser-4.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/eraser-50.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/eraser-50.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/eraser-6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/eraser-6.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/eraser-8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/eraser-8.png
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/Images/Cursors/eraser.bmp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/Images/Cursors/eraser.bmp
--------------------------------------------------------------------------------
/src/TeamSketch/Assets/logo.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/Assets/logo.ico
--------------------------------------------------------------------------------
/src/TeamSketch/Converters/EnumToBooleanConverter.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Globalization;
3 | using Avalonia.Data;
4 | using Avalonia.Data.Converters;
5 |
6 | namespace TeamSketch.Converters;
7 |
8 | public sealed class EnumToBooleanConverter : IValueConverter
9 | {
10 | public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
11 | {
12 | return value?.Equals(parameter);
13 | }
14 |
15 | public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
16 | {
17 | return value?.Equals(true) == true ? parameter : BindingOperations.DoNothing;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/TeamSketch/DependencyInjection/Bootstrapper.cs:
--------------------------------------------------------------------------------
1 | using Splat;
2 | using TeamSketch.Services;
3 |
4 | namespace TeamSketch.DependencyInjection;
5 |
6 | public static class Bootstrapper
7 | {
8 | public static void Register(IMutableDependencyResolver services)
9 | {
10 | RegisterServices(services);
11 | }
12 |
13 | private static void RegisterServices(IMutableDependencyResolver services)
14 | {
15 | services.RegisterLazySingleton(() => new AppState());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/TeamSketch/DependencyInjection/ReadonlyDependencyResolverExtensions.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Splat;
3 |
4 | namespace TeamSketch.DependencyInjection;
5 |
6 | public static class ReadonlyDependencyResolverExtensions
7 | {
8 | public static TService GetRequiredService(this IReadonlyDependencyResolver resolver)
9 | {
10 | var service = resolver.GetService();
11 | if (service is null)
12 | {
13 | throw new InvalidOperationException($"Failed to resolve object of type {typeof(TService)}");
14 | }
15 |
16 | return service;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/TeamSketch/Globals.cs:
--------------------------------------------------------------------------------
1 | namespace TeamSketch;
2 |
3 | public static class Globals
4 | {
5 | public const int CanvasWidth = 1280;
6 | public const int CanvasHeight = 720;
7 |
8 | #if DEBUG
9 | public const string ServerUri = "http://localhost:5150";
10 | #else
11 | public const string ServerUri = "https://team-sketch.davidtimovski.com";
12 | #endif
13 |
14 | private static short renderingIntervalMs = 10;
15 | public static short RenderingIntervalMs
16 | {
17 | get => renderingIntervalMs;
18 | set
19 | {
20 | if (value > 1000)
21 | {
22 | renderingIntervalMs = 1000;
23 | }
24 | else if (value < 3)
25 | {
26 | renderingIntervalMs = 3;
27 | }
28 | else
29 | {
30 | renderingIntervalMs = value;
31 | }
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/TeamSketch/Models/BrushSettings.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using Avalonia;
4 | using Avalonia.Input;
5 | using Avalonia.Media;
6 | using Avalonia.Media.Imaging;
7 | using Avalonia.Platform;
8 |
9 | namespace TeamSketch.Models;
10 |
11 | public class BrushSettings
12 | {
13 | private static readonly Dictionary ColorLookup = new()
14 | {
15 | { ColorsEnum.Default, new SolidColorBrush(Color.FromRgb(34, 34, 34)) },
16 | { ColorsEnum.Eraser, new SolidColorBrush(Color.FromRgb(255, 255, 255)) },
17 | { ColorsEnum.Red, new SolidColorBrush(Color.FromRgb(235, 51, 36)) },
18 | { ColorsEnum.Blue, new SolidColorBrush(Color.FromRgb(0, 162, 232)) },
19 | { ColorsEnum.Green, new SolidColorBrush(Color.FromRgb(34, 177, 76)) },
20 | { ColorsEnum.Yellow, new SolidColorBrush(Color.FromRgb(255, 242, 0)) },
21 | { ColorsEnum.Orange, new SolidColorBrush(Color.FromRgb(255, 127, 39)) },
22 | { ColorsEnum.Purple, new SolidColorBrush(Color.FromRgb(163, 73, 164)) },
23 | { ColorsEnum.Pink, new SolidColorBrush(Color.FromRgb(255, 174, 201)) },
24 | { ColorsEnum.Gray, new SolidColorBrush(Color.FromRgb(195, 195, 195)) }
25 | };
26 | private static readonly Dictionary ThicknessLookup = new()
27 | {
28 | { ThicknessEnum.Thin, 2 },
29 | { ThicknessEnum.SemiThin, 4 },
30 | { ThicknessEnum.Medium, 6 },
31 | { ThicknessEnum.SemiThick, 8 },
32 | { ThicknessEnum.Thick, 10 },
33 | { ThicknessEnum.Eraser, 50 }
34 | };
35 | private readonly string _cursorsPath;
36 | private readonly Dictionary _cursorBrushPathLookup;
37 |
38 | /// Required parameter. Use empty string for unit testing.
39 | ///
40 | public BrushSettings(string cursorsPath)
41 | {
42 | _cursorsPath = cursorsPath ?? throw new ArgumentException("Argument required.", nameof(cursorsPath));
43 |
44 | if (_cursorsPath != string.Empty)
45 | {
46 | _cursorBrushPathLookup = new()
47 | {
48 | { ColorsEnum.Default, $"{_cursorsPath}/brush-black.png" },
49 | { ColorsEnum.Red, $"{_cursorsPath}/brush-red.png" },
50 | { ColorsEnum.Blue, $"{_cursorsPath}/brush-blue.png" },
51 | { ColorsEnum.Green, $"{_cursorsPath}/brush-green.png" },
52 | { ColorsEnum.Yellow, $"{_cursorsPath}/brush-yellow.png" },
53 | { ColorsEnum.Orange, $"{_cursorsPath}/brush-orange.png" },
54 | { ColorsEnum.Purple, $"{_cursorsPath}/brush-purple.png" },
55 | { ColorsEnum.Pink, $"{_cursorsPath}/brush-pink.png" },
56 | { ColorsEnum.Gray, $"{_cursorsPath}/brush-gray.png" }
57 | };
58 | }
59 |
60 | BrushColor = ColorsEnum.Default;
61 | BrushThickness = ThicknessEnum.SemiThin;
62 | }
63 |
64 | public event EventHandler BrushChanged;
65 |
66 | public Cursor Cursor { get; private set; }
67 |
68 | private ColorsEnum brushColor;
69 | public ColorsEnum BrushColor
70 | {
71 | get => brushColor;
72 | set
73 | {
74 | brushColor = value;
75 | ColorBrush = ColorLookup[value];
76 |
77 | if (string.IsNullOrEmpty(_cursorsPath))
78 | {
79 | // Setting cursor image is unnecessary when running benchmarks
80 | return;
81 | }
82 |
83 | if (value == ColorsEnum.Eraser)
84 | {
85 | var path = $"{_cursorsPath}/eraser-{Thickness}.png";
86 | Cursor = new(new Bitmap(AssetLoader.Open(new Uri(path))), new PixelPoint((int)HalfThickness, (int)HalfThickness));
87 | }
88 | else
89 | {
90 | var path = _cursorBrushPathLookup[value];
91 | Cursor = new(new Bitmap(AssetLoader.Open(new Uri(path))), new PixelPoint(0, 0));
92 | }
93 |
94 | BrushChanged?.Invoke(null, new BrushChangedEventArgs { Cursor = Cursor });
95 | }
96 | }
97 |
98 | public SolidColorBrush ColorBrush { get; private set; }
99 |
100 | private ThicknessEnum brushThickness;
101 | public ThicknessEnum BrushThickness
102 | {
103 | get => brushThickness;
104 | set
105 | {
106 | brushThickness = value;
107 | Thickness = ThicknessLookup[value];
108 | HalfThickness = Thickness / 2;
109 |
110 | MaxBrushPointX = Globals.CanvasWidth - HalfThickness;
111 | MaxBrushPointY = Globals.CanvasHeight - HalfThickness;
112 | MinBrushPoint = HalfThickness;
113 |
114 | if (brushColor == ColorsEnum.Eraser)
115 | {
116 | var path = $"{_cursorsPath}/eraser-{Thickness}.png";
117 | Cursor = new(new Bitmap(AssetLoader.Open(new Uri(path))), new PixelPoint((int)HalfThickness, (int)HalfThickness));
118 | }
119 |
120 | BrushChanged?.Invoke(null, new BrushChangedEventArgs { Cursor = Cursor });
121 | }
122 | }
123 |
124 | public double Thickness { get; private set; }
125 | public double HalfThickness { get; private set; }
126 |
127 | public double MaxBrushPointX { get; private set; }
128 | public double MaxBrushPointY { get; private set; }
129 | public double MinBrushPoint { get; private set; }
130 |
131 | public static SolidColorBrush FindColorBrush(byte color)
132 | {
133 | return ColorLookup[(ColorsEnum)color];
134 | }
135 |
136 | public static double FindThickness(byte thickness)
137 | {
138 | return ThicknessLookup[(ThicknessEnum)thickness];
139 | }
140 | }
141 |
142 | public sealed class BrushChangedEventArgs : EventArgs
143 | {
144 | public required Cursor Cursor { get; init; }
145 | }
146 |
--------------------------------------------------------------------------------
/src/TeamSketch/Models/ColorsEnum.cs:
--------------------------------------------------------------------------------
1 | namespace TeamSketch.Models;
2 |
3 | public enum ColorsEnum
4 | {
5 | Default,
6 | Eraser,
7 | Red,
8 | Blue,
9 | Green,
10 | Yellow,
11 | Orange,
12 | Purple,
13 | Pink,
14 | Gray
15 | }
16 |
--------------------------------------------------------------------------------
/src/TeamSketch/Models/ThicknessEnum.cs:
--------------------------------------------------------------------------------
1 | namespace TeamSketch.Models;
2 |
3 | public enum ThicknessEnum
4 | {
5 | Thin,
6 | SemiThin,
7 | Medium,
8 | SemiThick,
9 | Thick,
10 | Eraser
11 | }
12 |
--------------------------------------------------------------------------------
/src/TeamSketch/Program.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Avalonia;
3 | using Avalonia.ReactiveUI;
4 | using Splat;
5 | using TeamSketch.DependencyInjection;
6 |
7 | namespace TeamSketch;
8 |
9 | internal class Program
10 | {
11 | // Initialization code. Don't use any Avalonia, third-party APIs or any
12 | // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
13 | // yet and stuff might break.
14 | [STAThread]
15 | public static void Main(string[] args)
16 | {
17 | Bootstrapper.Register(Locator.CurrentMutable);
18 |
19 | BuildAvaloniaApp()
20 | .StartWithClassicDesktopLifetime(args);
21 | }
22 |
23 | // Avalonia configuration, don't remove; also used by visual designer.
24 | private static AppBuilder BuildAvaloniaApp()
25 | => AppBuilder.Configure()
26 | .UsePlatformDetect()
27 | .LogToTrace()
28 | .UseReactiveUI();
29 | }
30 |
--------------------------------------------------------------------------------
/src/TeamSketch/Properties/PublishProfiles/Win64.pubxml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 | Release
8 | Any CPU
9 | bin\Release\net8.0\publish\win-x64\
10 | FileSystem
11 | net8.0
12 | win-x64
13 | true
14 | false
15 | true
16 | false
17 |
18 |
--------------------------------------------------------------------------------
/src/TeamSketch/Serialization/SourceGenerationContext.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Text.Json.Serialization;
3 | using TeamSketch.Common.ApiModels;
4 |
5 | namespace TeamSketch.Serialization;
6 |
7 | [JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
8 | [JsonSerializable(typeof(JoinRoomValidationResult))]
9 | [JsonSerializable(typeof(List))]
10 | internal partial class SourceGenerationContext : JsonSerializerContext
11 | {
12 | }
13 |
--------------------------------------------------------------------------------
/src/TeamSketch/Services/AppState.cs:
--------------------------------------------------------------------------------
1 | using TeamSketch.Models;
2 |
3 | namespace TeamSketch.Services;
4 |
5 | public interface IAppState
6 | {
7 | string Nickname { get; set; }
8 | string Room { get; set; }
9 | public BrushSettings BrushSettings { get; }
10 | }
11 |
12 | public sealed class AppState : IAppState
13 | {
14 | public string Nickname { get; set; }
15 | public string Room { get; set; }
16 | public BrushSettings BrushSettings { get; } = new("avares://TeamSketch/Assets/Images/Cursors");
17 | }
18 |
--------------------------------------------------------------------------------
/src/TeamSketch/Services/Renderer.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Linq;
3 | using Avalonia;
4 | using Avalonia.Controls;
5 | using Avalonia.Controls.Shapes;
6 | using Avalonia.Media;
7 | using TeamSketch.Models;
8 |
9 | namespace TeamSketch.Services;
10 |
11 | public interface IRenderer
12 | {
13 | void DrawPoint(double x, double y);
14 | void EnqueueLineSegment(Point point1, Point point2);
15 |
16 | ///
17 | /// Render line from locally batched points.
18 | ///
19 | /// The points making up the line that was rendered.
20 | List RenderLine();
21 |
22 | ///
23 | /// Render line from the input points.
24 | ///
25 | ///
26 | ///
27 | ///
28 | void RenderLine(Queue linePointsQueue, double thickness, SolidColorBrush colorBrush);
29 |
30 | Point RestrictPointToCanvas(double x, double y);
31 | }
32 |
33 | public sealed class Renderer : IRenderer
34 | {
35 | private readonly BrushSettings _brushSettings;
36 | private readonly Canvas _canvas;
37 | private readonly Queue _linePointsQueue = new();
38 |
39 | public Renderer(BrushSettings brushSettings, Canvas canvas)
40 | {
41 | _brushSettings = brushSettings;
42 | _canvas = canvas;
43 | }
44 |
45 | public void DrawPoint(double x, double y)
46 | {
47 | var ellipse = new Ellipse
48 | {
49 | Margin = new Thickness(x - _brushSettings.HalfThickness, y - _brushSettings.HalfThickness, 0, 0),
50 | Fill = _brushSettings.ColorBrush,
51 | Width = _brushSettings.Thickness,
52 | Height = _brushSettings.Thickness
53 | };
54 | _canvas.Children.Add(ellipse);
55 | }
56 |
57 | public void EnqueueLineSegment(Point point1, Point point2)
58 | {
59 | _linePointsQueue.Enqueue(point1);
60 | _linePointsQueue.Enqueue(point2);
61 | }
62 |
63 | public List RenderLine()
64 | {
65 | if (_linePointsQueue.Count == 0)
66 | {
67 | return [];
68 | }
69 |
70 | var myPointCollection = new Points();
71 |
72 | var result = _linePointsQueue.ToList();
73 | var firstPoint = _linePointsQueue.Dequeue();
74 |
75 | while (_linePointsQueue.Count > 0)
76 | {
77 | var point = _linePointsQueue.Dequeue();
78 | myPointCollection.Add(point);
79 | }
80 |
81 | var pathGeometry = new PathGeometry { Figures = [] };
82 | var pathFigure = new PathFigure
83 | {
84 | Segments =
85 | [
86 | new PolyLineSegment
87 | {
88 | Points = myPointCollection
89 | }
90 | ],
91 | StartPoint = firstPoint,
92 | IsClosed = false
93 | };
94 | pathGeometry.Figures.Add(pathFigure);
95 |
96 | var path = new Path
97 | {
98 | Stroke = _brushSettings.ColorBrush,
99 | StrokeThickness = _brushSettings.Thickness,
100 | Data = pathGeometry
101 | };
102 | _canvas.Children.Add(path);
103 |
104 | var ellipse = new Ellipse
105 | {
106 | Margin = new Thickness(firstPoint.X - _brushSettings.HalfThickness, firstPoint.Y - _brushSettings.HalfThickness, 0, 0),
107 | Fill = _brushSettings.ColorBrush,
108 | Width = _brushSettings.Thickness,
109 | Height = _brushSettings.Thickness
110 | };
111 | _canvas.Children.Add(ellipse);
112 |
113 | return result;
114 | }
115 |
116 | public void RenderLine(Queue linePointsQueue, double thickness, SolidColorBrush colorBrush)
117 | {
118 | if (linePointsQueue.Count == 0)
119 | {
120 | return;
121 | }
122 |
123 | var myPointCollection = new Points();
124 |
125 | var firstPoint = linePointsQueue.Dequeue();
126 |
127 | while (linePointsQueue.Count > 0)
128 | {
129 | var point = linePointsQueue.Dequeue();
130 | myPointCollection.Add(point);
131 | }
132 |
133 | var pathGeometry = new PathGeometry { Figures = [] };
134 | var pathFigure = new PathFigure
135 | {
136 | Segments =
137 | [
138 | new PolyLineSegment
139 | {
140 | Points = myPointCollection
141 | }
142 | ],
143 | StartPoint = firstPoint,
144 | IsClosed = false
145 | };
146 | pathGeometry.Figures.Add(pathFigure);
147 |
148 | var path = new Path
149 | {
150 | Stroke = colorBrush,
151 | StrokeThickness = thickness,
152 | Data = pathGeometry
153 | };
154 | _canvas.Children.Add(path);
155 |
156 | var ellipse = new Ellipse
157 | {
158 | Margin = new Thickness(firstPoint.X - thickness / 2, firstPoint.Y - thickness / 2, 0, 0),
159 | Fill = colorBrush,
160 | Width = thickness,
161 | Height = thickness
162 | };
163 | _canvas.Children.Add(ellipse);
164 | }
165 |
166 | public Point RestrictPointToCanvas(double x, double y)
167 | {
168 | if (x > _brushSettings.MaxBrushPointX)
169 | {
170 | x = _brushSettings.MaxBrushPointX;
171 | }
172 | else if (x < _brushSettings.MinBrushPoint)
173 | {
174 | x = _brushSettings.MinBrushPoint;
175 | }
176 |
177 | if (y > _brushSettings.MaxBrushPointY)
178 | {
179 | y = _brushSettings.MaxBrushPointY;
180 | }
181 | else if (y < _brushSettings.MinBrushPoint)
182 | {
183 | y = _brushSettings.MinBrushPoint;
184 | }
185 |
186 | return new Point(x, y);
187 | }
188 | }
189 |
--------------------------------------------------------------------------------
/src/TeamSketch/Services/SignalRService.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Threading.Tasks;
3 | using Avalonia;
4 | using Microsoft.AspNetCore.SignalR.Client;
5 | using Microsoft.Extensions.DependencyInjection;
6 | using TeamSketch.Utils;
7 |
8 | namespace TeamSketch.Services;
9 |
10 | public interface ISignalRService
11 | {
12 | HubConnection Connection { get; }
13 | Task CreateRoomAsync();
14 | Task JoinRoomAsync();
15 | Task JoinRandomRoomAsync();
16 | Task DrawPointAsync(double x, double y);
17 | Task DrawLineAsync(List points);
18 | }
19 |
20 | public sealed class SignalRService : ISignalRService
21 | {
22 | private readonly IAppState _appState;
23 |
24 | public SignalRService(IAppState appState)
25 | {
26 | _appState = appState;
27 |
28 | Connection = new HubConnectionBuilder()
29 | .WithUrl(Globals.ServerUri + "/actionHub")
30 | .WithAutomaticReconnect()
31 | .AddMessagePackProtocol()
32 | .Build();
33 | }
34 |
35 | public HubConnection Connection { get; }
36 |
37 | public async Task CreateRoomAsync()
38 | {
39 | Connection.On("RoomCreated", room =>
40 | {
41 | _appState.Room = room;
42 | });
43 |
44 | await Connection.StartAsync();
45 | await Connection.InvokeAsync("CreateRoom", _appState.Nickname);
46 | }
47 |
48 | public async Task JoinRoomAsync()
49 | {
50 | await Connection.StartAsync();
51 | await Connection.InvokeAsync("JoinRoom", _appState.Nickname, _appState.Room);
52 | }
53 |
54 | public async Task JoinRandomRoomAsync()
55 | {
56 | await Connection.StartAsync();
57 | await Connection.InvokeAsync("JoinRandomRoom", _appState.Nickname);
58 | }
59 |
60 | public async Task DrawPointAsync(double x, double y)
61 | {
62 | var data = PayloadConverter.ToBytes(x, y, _appState.BrushSettings.BrushThickness, _appState.BrushSettings.BrushColor);
63 | await Connection.InvokeAsync("DrawPoint", _appState.Nickname, _appState.Room, data);
64 | }
65 |
66 | public async Task DrawLineAsync(List points)
67 | {
68 | var data = PayloadConverter.ToBytes(points, _appState.BrushSettings.BrushThickness, _appState.BrushSettings.BrushColor);
69 | await Connection.InvokeAsync("DrawLine", _appState.Nickname, _appState.Room, data);
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/TeamSketch/TeamSketch.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | WinExe
4 | net8.0
5 | true
6 | true
7 |
8 | false
9 | link
10 | logo.ico
11 | Team Sketch
12 | Copyright © David Timovski 2025
13 | David Timovski
14 | 0.10.0
15 | 0.10.0
16 | 0.10.0
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | ParticipantsPanel.axaml
41 |
42 |
43 | MainWindow.axaml
44 |
45 |
46 | LobbyWindow.axaml
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/src/TeamSketch/UserControls/ConnectionStatus.axaml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
17 |
21 |
24 |
28 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | Reconnecting..
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/src/TeamSketch/UserControls/ConnectionStatus.axaml.cs:
--------------------------------------------------------------------------------
1 | using Avalonia.Controls;
2 |
3 | namespace TeamSketch.UserControls;
4 |
5 | public partial class ConnectionStatus : UserControl
6 | {
7 | public ConnectionStatus()
8 | {
9 | InitializeComponent();
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/TeamSketch/UserControls/EventsPanel.axaml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
11 |
14 |
15 |
18 |
21 |
25 |
29 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | Events
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/src/TeamSketch/UserControls/EventsPanel.axaml.cs:
--------------------------------------------------------------------------------
1 | using Avalonia.Controls;
2 |
3 | namespace TeamSketch.UserControls;
4 |
5 | public partial class EventsPanel : UserControl
6 | {
7 | public EventsPanel()
8 | {
9 | InitializeComponent();
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/TeamSketch/UserControls/ParticipantsPanel.axaml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
11 |
14 |
15 |
21 |
27 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | Participants
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/src/TeamSketch/UserControls/ParticipantsPanel.axaml.cs:
--------------------------------------------------------------------------------
1 | using Avalonia.Controls;
2 |
3 | namespace TeamSketch.UserControls;
4 |
5 | public partial class ParticipantsPanel : UserControl
6 | {
7 | public ParticipantsPanel()
8 | {
9 | InitializeComponent();
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/TeamSketch/UserControls/ToolsPanel.axaml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
13 |
16 |
17 |
31 |
34 |
37 |
40 |
43 |
46 |
49 |
53 |
57 |
60 |
64 |
68 |
69 |
82 |
85 |
88 |
89 |
102 |
105 |
108 |
112 |
116 |
120 |
124 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 | Tools
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
--------------------------------------------------------------------------------
/src/TeamSketch/UserControls/ToolsPanel.axaml.cs:
--------------------------------------------------------------------------------
1 | using Avalonia.Controls;
2 |
3 | namespace TeamSketch.UserControls;
4 |
5 | public partial class ToolsPanel : UserControl
6 | {
7 | public ToolsPanel()
8 | {
9 | InitializeComponent();
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/TeamSketch/Utils/HttpProxy.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Net;
4 | using System.Net.Http;
5 | using System.Text.Json;
6 | using System.Threading.Tasks;
7 | using TeamSketch.Common.ApiModels;
8 | using TeamSketch.Serialization;
9 |
10 | namespace TeamSketch.Utils;
11 |
12 | public static class HttpProxy
13 | {
14 | private static readonly HttpClient HttpClient = new();
15 |
16 | static HttpProxy()
17 | {
18 | HttpClient.BaseAddress = new Uri(Globals.ServerUri + "/api/");
19 | HttpClient.DefaultRequestHeaders.Add(HttpRequestHeader.Accept.ToString(), "application/json");
20 | }
21 |
22 | public static async Task ValidateJoinRoomAsync(string room, string nickname)
23 | {
24 | var response = await HttpClient.GetAsync($"rooms/{room}/validate-join/{nickname}");
25 | response.EnsureSuccessStatusCode();
26 |
27 | var content = await response.Content.ReadAsStringAsync();
28 |
29 | return JsonSerializer.Deserialize(content, SourceGenerationContext.Default.JoinRoomValidationResult);
30 | }
31 |
32 | public static async Task> GetParticipantsAsync(string room)
33 | {
34 | var response = await HttpClient.GetAsync($"rooms/{room}/participants");
35 | response.EnsureSuccessStatusCode();
36 |
37 | var content = await response.Content.ReadAsStringAsync();
38 |
39 | return JsonSerializer.Deserialize(content, SourceGenerationContext.Default.ListString);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/TeamSketch/Utils/PayloadConverter.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using Avalonia;
3 | using Avalonia.Controls.Shapes;
4 | using Avalonia.Media;
5 | using TeamSketch.Models;
6 |
7 | namespace TeamSketch.Utils;
8 |
9 | public static class PayloadConverter
10 | {
11 | public static byte[] ToBytes(double x, double y, ThicknessEnum size, ColorsEnum color)
12 | {
13 | var bytes = new byte[6];
14 |
15 | bytes[0] = (byte)x;
16 | bytes[1] = (byte)((short)x >> 8);
17 |
18 | bytes[2] = (byte)y;
19 | bytes[3] = (byte)((short)y >> 8);
20 |
21 | bytes[4] = (byte)size;
22 | bytes[5] = (byte)color;
23 |
24 | return bytes;
25 | }
26 |
27 | public static Ellipse ToPoint(byte[] bytes)
28 | {
29 | var x = (bytes[1] << 8) + bytes[0];
30 | var y = (bytes[3] << 8) + bytes[2];
31 | var size = BrushSettings.FindThickness(bytes[4]);
32 | var colorBrush = BrushSettings.FindColorBrush(bytes[5]);
33 |
34 | var ellipse = new Ellipse
35 | {
36 | Margin = new Thickness(x - (size / 2), y - (size / 2), 0, 0),
37 | Fill = colorBrush,
38 | Width = size,
39 | Height = size
40 | };
41 |
42 | return ellipse;
43 | }
44 |
45 | public static byte[] ToBytes(List points, ThicknessEnum thickness, ColorsEnum color)
46 | {
47 | var bytes = new byte[1 + points.Count * 4 + 2];
48 | int currentIndex = 0;
49 |
50 | bytes[currentIndex++] = (byte)points.Count;
51 |
52 | foreach (var point in points)
53 | {
54 | bytes[currentIndex++] = (byte)point.X;
55 | bytes[currentIndex++] = (byte)((short)point.X >> 8);
56 |
57 | bytes[currentIndex++] = (byte)point.Y;
58 | bytes[currentIndex++] = (byte)((short)point.Y >> 8);
59 | }
60 |
61 | bytes[currentIndex++] = (byte)thickness;
62 | bytes[currentIndex] = (byte)color;
63 |
64 | return bytes;
65 | }
66 |
67 | public static (Queue points, double thickness, SolidColorBrush colorBrush) ToLine(byte[] bytes)
68 | {
69 | int currentIndex = 0;
70 | var count = (int)bytes[currentIndex++];
71 |
72 | var result = new Queue(count);
73 |
74 | for (var i = 0; i < count; i++)
75 | {
76 | var buffer = i * 4;
77 |
78 | var x = (bytes[buffer + 2] << 8) + bytes[buffer + 1];
79 | var y = (bytes[buffer + 4] << 8) + bytes[buffer + 3];
80 | result.Enqueue(new Point(x, y));
81 | }
82 |
83 | var size = BrushSettings.FindThickness(bytes[^2]);
84 | var colorBrush = BrushSettings.FindColorBrush(bytes[^1]);
85 |
86 | return (result, size, colorBrush);
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/TeamSketch/ViewModels/ErrorViewModel.cs:
--------------------------------------------------------------------------------
1 | using ReactiveUI;
2 |
3 | namespace TeamSketch.ViewModels;
4 |
5 | public sealed class ErrorViewModel : ReactiveObject
6 | {
7 | public ErrorViewModel(string message, bool isSystemError)
8 | {
9 | Title = isSystemError ? "Whoops!" : "Sorry, but..";
10 | Message = message;
11 | }
12 |
13 | public string Title { get; private set; }
14 | public string Message { get; private set; }
15 | }
16 |
--------------------------------------------------------------------------------
/src/TeamSketch/ViewModels/LobbyViewModel.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Tasks;
2 | using ReactiveUI;
3 | using Splat;
4 | using TeamSketch.Common;
5 | using TeamSketch.DependencyInjection;
6 | using TeamSketch.Services;
7 | using TeamSketch.Utils;
8 |
9 | namespace TeamSketch.ViewModels;
10 |
11 | public sealed class LobbyViewModel : ReactiveObject
12 | {
13 | private readonly IAppState _appState;
14 |
15 | public LobbyViewModel(bool fromMainWindow = false)
16 | {
17 | _appState = Locator.Current.GetRequiredService();
18 | SignalRService = new SignalRService(_appState);
19 |
20 | if (fromMainWindow)
21 | {
22 | joinTabSelected = true;
23 | nickname = _appState.Nickname;
24 | room = _appState.Room;
25 | }
26 | else
27 | {
28 | createTabSelected = true;
29 | }
30 | }
31 |
32 | public ISignalRService SignalRService { get; }
33 |
34 | public async Task CreateRoomAsync()
35 | {
36 | Entering = true;
37 |
38 | if (nickname.Trim().Length == 0)
39 | {
40 | NicknameIsInvalid = true;
41 | Entering = false;
42 | return new EnterValidationResult(false, null, false, false);
43 | }
44 |
45 | var error = Validations.ValidateNickname(nickname);
46 | if (error != null)
47 | {
48 | NicknameIsInvalid = true;
49 | Entering = false;
50 | return new EnterValidationResult(false, error, true, false);
51 | }
52 |
53 | try
54 | {
55 | _appState.Nickname = nickname.Trim();
56 | await SignalRService.CreateRoomAsync();
57 | return new EnterValidationResult(true, null, false, false);
58 | }
59 | catch
60 | {
61 | Entering = false;
62 | return new EnterValidationResult(false, "Could not connect to the server. Please check your internet connection or try again later.", true, true);
63 | }
64 | }
65 |
66 | public async Task JoinRoomAsync()
67 | {
68 | Entering = true;
69 | NicknameIsInvalid = RoomIsInvalid = false;
70 |
71 | if (nickname.Trim().Length == 0)
72 | {
73 | NicknameIsInvalid = true;
74 | Entering = false;
75 | return new EnterValidationResult(false, null, false, false);
76 | }
77 |
78 | if (room.Trim().Length == 0)
79 | {
80 | RoomIsInvalid = true;
81 | Entering = false;
82 | return new EnterValidationResult(false, null, false, false);
83 | }
84 |
85 | var nicknameError = Validations.ValidateNickname(nickname);
86 | if (nicknameError != null)
87 | {
88 | NicknameIsInvalid = true;
89 | Entering = false;
90 | return new EnterValidationResult(false, nicknameError, true, false);
91 | }
92 |
93 | var roomError = Validations.ValidateRoomName(room);
94 | if (roomError != null)
95 | {
96 | RoomIsInvalid = true;
97 | Entering = false;
98 | return new EnterValidationResult(false, roomError, true, false);
99 | }
100 |
101 | try
102 | {
103 | var validationResult = await HttpProxy.ValidateJoinRoomAsync(room, nickname);
104 | if (!validationResult.RoomExists)
105 | {
106 | RoomIsInvalid = true;
107 | Entering = false;
108 | return new EnterValidationResult(false, "Room does not exist.", true, false);
109 | }
110 |
111 | if (validationResult.RoomIsFull)
112 | {
113 | RoomIsInvalid = true;
114 | Entering = false;
115 | return new EnterValidationResult(false, "Room is at capacity.", true, false);
116 | }
117 |
118 | if (validationResult.NicknameIsTaken)
119 | {
120 | RoomIsInvalid = true;
121 | Entering = false;
122 | return new EnterValidationResult(false, "The nickname is taken in that room.", true, false);
123 | }
124 |
125 | _appState.Nickname = nickname.Trim();
126 | _appState.Room = room.Trim();
127 | await SignalRService.JoinRoomAsync();
128 | return new EnterValidationResult(true, null, false, false);
129 | }
130 | catch
131 | {
132 | Entering = false;
133 | return new EnterValidationResult(false, "Could not connect to the server. Please check your internet connection or try again later.", true, true);
134 | }
135 | }
136 |
137 | public async Task JoinRandomRoomAsync()
138 | {
139 | Entering = true;
140 |
141 | if (nickname.Trim().Length == 0)
142 | {
143 | NicknameIsInvalid = true;
144 | Entering = false;
145 | return new EnterValidationResult(false, null, false, false);
146 | }
147 |
148 | var error = Validations.ValidateNickname(nickname);
149 | if (error != null)
150 | {
151 | NicknameIsInvalid = true;
152 | Entering = false;
153 | return new EnterValidationResult(false, error, true, false);
154 | }
155 |
156 | try
157 | {
158 | _appState.Nickname = nickname.Trim();
159 | await SignalRService.JoinRandomRoomAsync();
160 | return new EnterValidationResult(true, null, false, false);
161 | }
162 | catch
163 | {
164 | Entering = false;
165 | return new EnterValidationResult(false, "Could not connect to the server. Please check your internet connection or try again later.", true, true);
166 | }
167 | }
168 |
169 | public async Task ExitRandomRoomQueueAsync()
170 | {
171 | Entering = false;
172 |
173 | try
174 | {
175 | await SignalRService.Connection.StopAsync();
176 | }
177 | catch
178 | {
179 | Entering = false;
180 | }
181 | }
182 |
183 | public void SelectTab(object tab)
184 | {
185 | if (Entering)
186 | {
187 | return;
188 | }
189 |
190 | switch (tab)
191 | {
192 | case "create":
193 | JoinTabSelected = JoinRandomTabSelected = false;
194 | CreateTabSelected = true;
195 | break;
196 | case "join":
197 | CreateTabSelected = JoinRandomTabSelected = false;
198 | JoinTabSelected = true;
199 | break;
200 | case "random":
201 | CreateTabSelected = JoinTabSelected = false;
202 | JoinRandomTabSelected = true;
203 | break;
204 | }
205 | }
206 |
207 | private bool createTabSelected;
208 | public bool CreateTabSelected
209 | {
210 | get => createTabSelected;
211 | set => this.RaiseAndSetIfChanged(ref createTabSelected, value);
212 | }
213 |
214 | private bool joinTabSelected;
215 | public bool JoinTabSelected
216 | {
217 | get => joinTabSelected;
218 | set => this.RaiseAndSetIfChanged(ref joinTabSelected, value);
219 | }
220 |
221 | private bool joinRandomTabSelected;
222 | public bool JoinRandomTabSelected
223 | {
224 | get => joinRandomTabSelected;
225 | set => this.RaiseAndSetIfChanged(ref joinRandomTabSelected, value);
226 | }
227 |
228 | private string nickname = string.Empty;
229 | public string Nickname
230 | {
231 | get => nickname;
232 | set => this.RaiseAndSetIfChanged(ref nickname, value);
233 | }
234 |
235 | private bool nicknameIsInvalid;
236 | public bool NicknameIsInvalid
237 | {
238 | get => nicknameIsInvalid;
239 | set => this.RaiseAndSetIfChanged(ref nicknameIsInvalid, value);
240 | }
241 |
242 | private string room = string.Empty;
243 | public string Room
244 | {
245 | get => room;
246 | set => this.RaiseAndSetIfChanged(ref room, value);
247 | }
248 |
249 | private bool roomIsInvalid;
250 | public bool RoomIsInvalid
251 | {
252 | get => roomIsInvalid;
253 | set => this.RaiseAndSetIfChanged(ref roomIsInvalid, value);
254 | }
255 |
256 | private string createButtonLabel = "Create";
257 | public string CreateButtonLabel
258 | {
259 | get => createButtonLabel;
260 | set => this.RaiseAndSetIfChanged(ref createButtonLabel, value);
261 | }
262 |
263 | private string joinButtonLabel = "Join";
264 | public string JoinButtonLabel
265 | {
266 | get => joinButtonLabel;
267 | set => this.RaiseAndSetIfChanged(ref joinButtonLabel, value);
268 | }
269 |
270 | private string joinRandomButtonLabel = "Join random";
271 | public string JoinRandomButtonLabel
272 | {
273 | get => joinRandomButtonLabel;
274 | set => this.RaiseAndSetIfChanged(ref joinRandomButtonLabel, value);
275 | }
276 |
277 | private bool entering;
278 | public bool Entering
279 | {
280 | get => entering;
281 | set
282 | {
283 | this.RaiseAndSetIfChanged(ref entering, value);
284 | CreateButtonLabel = value && createTabSelected ? "Creating" : "Create";
285 | JoinButtonLabel = value && joinTabSelected ? "Joining" : "Join";
286 | JoinRandomButtonLabel = value && joinRandomTabSelected ? "In queue" : "Join random";
287 | }
288 | }
289 | }
290 |
291 | public readonly record struct EnterValidationResult(bool Success, string ErrorMessage, bool ShowError, bool IsSystemError);
292 |
--------------------------------------------------------------------------------
/src/TeamSketch/ViewModels/MainWindowViewModel.cs:
--------------------------------------------------------------------------------
1 | using System.Linq;
2 | using System.Threading.Tasks;
3 | using ReactiveUI;
4 | using Splat;
5 | using TeamSketch.DependencyInjection;
6 | using TeamSketch.Services;
7 | using TeamSketch.Utils;
8 | using TeamSketch.ViewModels.UserControls;
9 |
10 | namespace TeamSketch.ViewModels;
11 |
12 | public sealed class MainWindowViewModel : ReactiveObject
13 | {
14 | public MainWindowViewModel(ISignalRService signalRService)
15 | {
16 | var appState = Locator.Current.GetRequiredService();
17 | SignalRService = signalRService;
18 |
19 | Room = appState.Room;
20 |
21 | toolsPanel = new ToolsPanelViewModel(appState.BrushSettings);
22 | participantsPanel = new ParticipantsPanelViewModel(signalRService);
23 | eventsPanel = new EventsPanelViewModel(signalRService);
24 | connectionStatus = new ConnectionStatusViewModel(signalRService);
25 |
26 | _ = GetParticipantsInRoomAsync();
27 | }
28 |
29 | public ISignalRService SignalRService { get; }
30 | public string Room { get; }
31 |
32 | public void IndicateDrawing(string nickname)
33 | {
34 | ParticipantViewModel participant = ParticipantsPanel.Participants.FirstOrDefault(x => x.Nickname == nickname);
35 | if (participant != null)
36 | {
37 | participant.Drawing = true;
38 | }
39 | }
40 |
41 | private async Task GetParticipantsInRoomAsync()
42 | {
43 | var participants = await HttpProxy.GetParticipantsAsync(Room);
44 |
45 | foreach (var participantNickname in participants)
46 | {
47 | ParticipantsPanel.Participants.Add(new ParticipantViewModel(participantNickname));
48 | }
49 |
50 | var initialEventMessage = participants.Count == 1 ? "Room created." : "Joined room.";
51 | EventsPanel.Events.Add(new EventViewModel(initialEventMessage));
52 | }
53 |
54 | private ToolsPanelViewModel toolsPanel;
55 | public ToolsPanelViewModel ToolsPanel
56 | {
57 | get => toolsPanel;
58 | set => this.RaiseAndSetIfChanged(ref toolsPanel, value);
59 | }
60 |
61 | private ParticipantsPanelViewModel participantsPanel;
62 | public ParticipantsPanelViewModel ParticipantsPanel
63 | {
64 | get => participantsPanel;
65 | set => this.RaiseAndSetIfChanged(ref participantsPanel, value);
66 | }
67 |
68 | private EventsPanelViewModel eventsPanel;
69 | public EventsPanelViewModel EventsPanel
70 | {
71 | get => eventsPanel;
72 | set => this.RaiseAndSetIfChanged(ref eventsPanel, value);
73 | }
74 |
75 | private ConnectionStatusViewModel connectionStatus;
76 | public ConnectionStatusViewModel ConnectionStatus
77 | {
78 | get => connectionStatus;
79 | set => this.RaiseAndSetIfChanged(ref connectionStatus, value);
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/TeamSketch/ViewModels/UserControls/ConnectionStatusViewModel.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading.Tasks;
3 | using Avalonia.Threading;
4 | using Microsoft.AspNetCore.SignalR.Client;
5 | using ReactiveUI;
6 | using TeamSketch.Services;
7 |
8 | namespace TeamSketch.ViewModels.UserControls;
9 |
10 | public sealed class ConnectionStatusViewModel : ReactiveObject
11 | {
12 | private readonly ISignalRService _signalRService;
13 | private readonly DispatcherTimer _pingTimer = new();
14 | private DateTime lastPing;
15 |
16 | public ConnectionStatusViewModel(ISignalRService signalRService)
17 | {
18 | _signalRService = signalRService;
19 | _signalRService.Connection.On("Pong", () =>
20 | {
21 | TimeSpan diff = DateTime.Now - lastPing;
22 | Latency = diff.Milliseconds;
23 | });
24 | _signalRService.Connection.Reconnecting += Connection_Reconnecting;
25 | _signalRService.Connection.Reconnected += Connection_Reconnected;
26 | _signalRService.Connection.Closed += Connection_Closed;
27 |
28 | _pingTimer.Tick += PingTimer_Tick;
29 | _pingTimer.Interval = TimeSpan.FromSeconds(3);
30 | _pingTimer.Start();
31 | }
32 |
33 | private Task Connection_Reconnecting(Exception arg)
34 | {
35 | _pingTimer.Stop();
36 |
37 | Connected = false;
38 |
39 | return Task.CompletedTask;
40 | }
41 |
42 | private Task Connection_Reconnected(string arg)
43 | {
44 | _pingTimer.Start();
45 |
46 | Connected = true;
47 |
48 | return Task.CompletedTask;
49 | }
50 |
51 | private Task Connection_Closed(Exception arg)
52 | {
53 | _pingTimer.Stop();
54 | return Task.CompletedTask;
55 | }
56 |
57 | private async void PingTimer_Tick(object sender, EventArgs e)
58 | {
59 | lastPing = DateTime.Now;
60 | await _signalRService.Connection.InvokeAsync("Ping");
61 | }
62 |
63 | private bool connected = true;
64 | public bool Connected
65 | {
66 | get => connected;
67 | set => this.RaiseAndSetIfChanged(ref connected, value);
68 | }
69 |
70 | private int latency;
71 | public int Latency
72 | {
73 | get => latency;
74 | set => this.RaiseAndSetIfChanged(ref latency, value);
75 | }
76 |
77 | private string reconnectingLabel = "Reconnecting";
78 | public string ReconnectingLabel
79 | {
80 | get => reconnectingLabel;
81 | set => this.RaiseAndSetIfChanged(ref reconnectingLabel, value);
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/TeamSketch/ViewModels/UserControls/EventsPanelViewModel.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.ObjectModel;
3 | using System.Threading.Tasks;
4 | using Microsoft.AspNetCore.SignalR.Client;
5 | using ReactiveUI;
6 | using TeamSketch.Services;
7 |
8 | namespace TeamSketch.ViewModels.UserControls;
9 |
10 | public sealed class EventsPanelViewModel : ReactiveObject
11 | {
12 | public EventsPanelViewModel(ISignalRService signalRService)
13 | {
14 | signalRService.Connection.Reconnecting += Connection_Reconnecting;
15 | signalRService.Connection.Reconnected += Connection_Reconnected;
16 | signalRService.Connection.On("JoinedRoom", Connection_JoinedRoom);
17 | signalRService.Connection.On("LeftRoom", Connection_LeftRoom);
18 | }
19 |
20 | public ObservableCollection Events { get; } = [];
21 |
22 | private Task Connection_Reconnecting(Exception arg)
23 | {
24 | Events.Add(new EventViewModel("Disconnected."));
25 | return Task.CompletedTask;
26 | }
27 |
28 | private Task Connection_Reconnected(string arg)
29 | {
30 | Events.Add(new EventViewModel("Reconnected."));
31 | return Task.CompletedTask;
32 | }
33 |
34 | private void Connection_JoinedRoom(string participant)
35 | {
36 | Events.Add(new EventViewModel(participant, " joined."));
37 | }
38 |
39 | private void Connection_LeftRoom(string participant)
40 | {
41 | Events.Add(new EventViewModel(participant, " left."));
42 | }
43 | }
44 |
45 | public class EventViewModel : ReactiveObject
46 | {
47 | public EventViewModel(string eventMessage)
48 | {
49 | EventMessage = eventMessage;
50 | }
51 |
52 | public EventViewModel(string participant, string eventMessage)
53 | {
54 | HasParticipant = true;
55 | Participant = participant;
56 | EventMessage = eventMessage;
57 | }
58 |
59 | public bool HasParticipant { get; private set; }
60 | public string Participant { get; private set; }
61 | public string EventMessage { get; private set; }
62 | }
63 |
--------------------------------------------------------------------------------
/src/TeamSketch/ViewModels/UserControls/ParticipantsPanelViewModel.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.ObjectModel;
3 | using System.Linq;
4 | using Avalonia.Threading;
5 | using Microsoft.AspNetCore.SignalR.Client;
6 | using ReactiveUI;
7 | using TeamSketch.Services;
8 |
9 | namespace TeamSketch.ViewModels.UserControls;
10 |
11 | public sealed class ParticipantsPanelViewModel : ReactiveObject
12 | {
13 | public ParticipantsPanelViewModel(ISignalRService signalRService)
14 | {
15 | signalRService.Connection.On("JoinedRoom", Connection_JoinedRoom);
16 | signalRService.Connection.On("LeftRoom", Connection_LeftRoom);
17 | }
18 |
19 | public ObservableCollection Participants { get; } = [];
20 |
21 | private void Connection_JoinedRoom(string nickname)
22 | {
23 | Participants.Add(new ParticipantViewModel(nickname));
24 | }
25 |
26 | private void Connection_LeftRoom(string nickname)
27 | {
28 | ParticipantViewModel participant = Participants.FirstOrDefault(x => x.Nickname == nickname);
29 | Participants.Remove(participant);
30 | }
31 | }
32 |
33 | public sealed class ParticipantViewModel : ReactiveObject
34 | {
35 | private readonly DispatcherTimer _drawingIndicatorTimer = new();
36 |
37 | public ParticipantViewModel(string nickname)
38 | {
39 | this.nickname = nickname;
40 |
41 | _drawingIndicatorTimer.Tick += DrawingIndicatorTimer_Tick;
42 | _drawingIndicatorTimer.Interval = TimeSpan.FromSeconds(1);
43 | }
44 |
45 | private string nickname;
46 | public string Nickname
47 | {
48 | get => nickname;
49 | set => this.RaiseAndSetIfChanged(ref nickname, value);
50 | }
51 |
52 | private bool drawing;
53 | public bool Drawing
54 | {
55 | get => drawing;
56 | set
57 | {
58 | this.RaiseAndSetIfChanged(ref drawing, value);
59 |
60 | if (!_drawingIndicatorTimer.IsEnabled)
61 | {
62 | _drawingIndicatorTimer.Start();
63 | }
64 | }
65 | }
66 |
67 | private void DrawingIndicatorTimer_Tick(object sender, EventArgs e)
68 | {
69 | Drawing = false;
70 | _drawingIndicatorTimer.Stop();
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/TeamSketch/ViewModels/UserControls/ToolsPanelViewModel.cs:
--------------------------------------------------------------------------------
1 | using ReactiveUI;
2 | using TeamSketch.Models;
3 |
4 | namespace TeamSketch.ViewModels.UserControls;
5 |
6 | public sealed class ToolsPanelViewModel : ReactiveObject
7 | {
8 | private readonly BrushSettings _brushSettings;
9 |
10 | public ToolsPanelViewModel(BrushSettings brushSettings)
11 | {
12 | _brushSettings = brushSettings;
13 |
14 | brushColor = _brushSettings.BrushColor;
15 | previousBrushThickness = _brushSettings.BrushThickness;
16 | brushThickness = _brushSettings.BrushThickness;
17 | }
18 |
19 | private ColorsEnum brushColor;
20 | public ColorsEnum BrushColor
21 | {
22 | get => brushColor;
23 | set
24 | {
25 | this.RaiseAndSetIfChanged(ref brushColor, value);
26 | _brushSettings.BrushColor = value;
27 |
28 | if (value == ColorsEnum.Eraser)
29 | {
30 | BrushThickness = ThicknessEnum.Eraser;
31 | }
32 | else
33 | {
34 | BrushThickness = previousBrushThickness;
35 | }
36 | }
37 | }
38 |
39 | private ThicknessEnum previousBrushThickness;
40 | private ThicknessEnum brushThickness;
41 | public ThicknessEnum BrushThickness
42 | {
43 | get => brushThickness;
44 | set
45 | {
46 | this.RaiseAndSetIfChanged(ref brushThickness, value);
47 | _brushSettings.BrushThickness = value;
48 |
49 | if (brushColor != ColorsEnum.Eraser || value != ThicknessEnum.Eraser)
50 | {
51 | previousBrushThickness = brushThickness;
52 | }
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/TeamSketch/Views/ErrorWindow.axaml:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
19 |
20 |
26 |
27 |
31 |
32 |
36 |
37 |
45 |
48 |
51 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/src/TeamSketch/Views/ErrorWindow.axaml.cs:
--------------------------------------------------------------------------------
1 | using Avalonia.Controls;
2 | using Avalonia.Interactivity;
3 |
4 | namespace TeamSketch.Views;
5 |
6 | public partial class ErrorWindow : Window
7 | {
8 | public ErrorWindow()
9 | {
10 | InitializeComponent();
11 | }
12 |
13 | private void OnCloseClick(object sender, RoutedEventArgs e)
14 | {
15 | Close();
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/TeamSketch/Views/LobbyWindow.axaml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
16 |
17 |
20 |
29 |
37 |
38 |
42 |
48 |
49 |
54 |
57 |
60 |
63 |
64 |
72 |
75 |
78 |
85 |
90 |
91 |
99 |
102 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
--------------------------------------------------------------------------------
/src/TeamSketch/Views/LobbyWindow.axaml.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading.Tasks;
3 | using Avalonia.Controls;
4 | using Microsoft.AspNetCore.SignalR.Client;
5 | using ReactiveUI;
6 | using Splat;
7 | using TeamSketch.DependencyInjection;
8 | using TeamSketch.Services;
9 | using TeamSketch.ViewModels;
10 |
11 | namespace TeamSketch.Views;
12 |
13 | public partial class LobbyWindow : Window
14 | {
15 | private readonly IAppState _appState;
16 |
17 | public LobbyWindow()
18 | {
19 | InitializeComponent();
20 |
21 | _appState = Locator.Current.GetRequiredService();
22 |
23 | CreateButton.Command = ReactiveCommand.Create(CreateButtonClicked);
24 | JoinButton.Command = ReactiveCommand.Create(JoinButtonClicked);
25 | JoinRandomButton.Command = ReactiveCommand.Create(JoinRandomButtonClicked);
26 | }
27 |
28 | protected override void OnDataContextChanged(EventArgs e)
29 | {
30 | var vm = (LobbyViewModel)DataContext;
31 |
32 | vm.SignalRService.Connection.On("RandomRoomJoined", (room) =>
33 | {
34 | _appState.Room = room;
35 | Start(vm.SignalRService);
36 | });
37 |
38 | base.OnDataContextChanged(e);
39 | }
40 |
41 | private async Task CreateButtonClicked()
42 | {
43 | var vm = (LobbyViewModel)DataContext;
44 |
45 | var result = await vm.CreateRoomAsync();
46 | if (result.Success)
47 | {
48 | Start(vm.SignalRService);
49 | }
50 | else if (result.ShowError)
51 | {
52 | ShowError(result.ErrorMessage, result.IsSystemError);
53 | }
54 | }
55 |
56 | private async Task JoinButtonClicked()
57 | {
58 | var vm = (LobbyViewModel)DataContext;
59 |
60 | var result = await vm.JoinRoomAsync();
61 | if (result.Success)
62 | {
63 | Start(vm.SignalRService);
64 | }
65 | else if (result.ShowError)
66 | {
67 | ShowError(result.ErrorMessage, result.IsSystemError);
68 | }
69 | }
70 |
71 | private async Task JoinRandomButtonClicked()
72 | {
73 | var vm = (LobbyViewModel)DataContext;
74 |
75 | var result = await vm.JoinRandomRoomAsync();
76 | if (result.ShowError)
77 | {
78 | ShowError(result.ErrorMessage, result.IsSystemError);
79 | }
80 | }
81 |
82 | private static void ShowError(string message, bool isSystemError)
83 | {
84 | var errorWindow = new ErrorWindow
85 | {
86 | DataContext = new ErrorViewModel(message, isSystemError),
87 | Topmost = true,
88 | CanResize = false
89 | };
90 | errorWindow.Show();
91 | errorWindow.Activate();
92 | }
93 |
94 | private void Start(ISignalRService signalRService)
95 | {
96 | var mainWindow = new MainWindow
97 | {
98 | DataContext = new MainWindowViewModel(signalRService)
99 | };
100 | mainWindow.Show();
101 | mainWindow.Activate();
102 |
103 | Close();
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src/TeamSketch/Views/MainWindow.axaml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
20 |
21 |
24 |
29 |
32 |
33 |
36 |
39 |
40 |
44 |
45 |
50 |
51 |
56 |
57 |
61 |
68 |
72 |
76 |
84 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
--------------------------------------------------------------------------------
/src/TeamSketch/Views/MainWindow.axaml.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Linq;
3 | using System.Threading.Tasks;
4 | using Avalonia;
5 | using Avalonia.Controls;
6 | using Avalonia.Input;
7 | using Avalonia.Interactivity;
8 | using Avalonia.Threading;
9 | using Microsoft.AspNetCore.SignalR.Client;
10 | using Splat;
11 | using TeamSketch.DependencyInjection;
12 | using TeamSketch.Models;
13 | using TeamSketch.Services;
14 | using TeamSketch.Utils;
15 | using TeamSketch.ViewModels;
16 |
17 | namespace TeamSketch.Views;
18 |
19 | public partial class MainWindow : Window
20 | {
21 | private readonly IAppState _appState;
22 | private readonly IRenderer _renderer;
23 | private readonly DispatcherTimer _lineRenderingTimer = new();
24 |
25 | private Point currentPoint = new();
26 | private bool pressed;
27 | private Action closeAdditionalAction = () => { };
28 | private bool isClosing;
29 |
30 | public MainWindow()
31 | {
32 | InitializeComponent();
33 |
34 | _appState = Locator.Current.GetRequiredService();
35 | _renderer = new Renderer(_appState.BrushSettings, Canvas);
36 |
37 | _lineRenderingTimer.Tick += LineRenderingTimer_Tick;
38 | _lineRenderingTimer.Interval = TimeSpan.FromMilliseconds(Globals.RenderingIntervalMs);
39 | _lineRenderingTimer.Start();
40 |
41 | Canvas.Cursor = _appState.BrushSettings.Cursor;
42 | Canvas.PointerMoved += Canvas_PointerMoved;
43 |
44 | _appState.BrushSettings.BrushChanged += BrushSettings_BrushChanged;
45 | }
46 |
47 | protected override void OnDataContextChanged(EventArgs e)
48 | {
49 | var vm = DataContext as MainWindowViewModel;
50 | vm.SignalRService.Connection.On("DrewPoint", Connection_ParticipantDrewPoint);
51 | vm.SignalRService.Connection.On("DrewLine", Connection_ParticipantDrewLine);
52 | vm.SignalRService.Connection.Closed += Connection_Closed;
53 |
54 | base.OnDataContextChanged(e);
55 | }
56 |
57 | private void LineRenderingTimer_Tick(object sender, EventArgs e)
58 | {
59 | Dispatcher.UIThread.InvokeAsync(() =>
60 | {
61 | var points = _renderer.RenderLine();
62 | if (!points.Any())
63 | {
64 | return;
65 | }
66 |
67 | var vm = DataContext as MainWindowViewModel;
68 | _ = vm.SignalRService.DrawLineAsync(points);
69 | });
70 | }
71 |
72 | private void BrushSettings_BrushChanged(object sender, BrushChangedEventArgs e)
73 | {
74 | Canvas.Cursor = e.Cursor;
75 | }
76 |
77 | private void Connection_ParticipantDrewPoint(string participant, byte[] data)
78 | {
79 | var point = PayloadConverter.ToPoint(data);
80 |
81 | Dispatcher.UIThread.InvokeAsync(() =>
82 | {
83 | Canvas.Children.Add(point);
84 | });
85 |
86 | IndicateDrawing(participant);
87 | }
88 |
89 | private void Connection_ParticipantDrewLine(string participant, byte[] data)
90 | {
91 | var (points, thickness, colorBrush) = PayloadConverter.ToLine(data);
92 |
93 | Dispatcher.UIThread.InvokeAsync(() =>
94 | {
95 | _renderer.RenderLine(points, thickness, colorBrush);
96 | });
97 |
98 | IndicateDrawing(participant);
99 | }
100 |
101 | private void Canvas_PointerPressed(object sender, PointerPressedEventArgs e)
102 | {
103 | currentPoint = e.GetPosition(Canvas);
104 | pressed = true;
105 | }
106 |
107 | private void Canvas_PointerReleased(object sender, PointerReleasedEventArgs e)
108 | {
109 | pressed = false;
110 |
111 | var newPoint = _renderer.RestrictPointToCanvas(currentPoint.X, currentPoint.Y);
112 | _renderer.DrawPoint(newPoint.X, newPoint.Y);
113 |
114 | var vm = DataContext as MainWindowViewModel;
115 | _ = vm.SignalRService.DrawPointAsync(newPoint.X, newPoint.Y);
116 |
117 | IndicateDrawing(_appState.Nickname);
118 | }
119 |
120 | private void Canvas_PointerMoved(object sender, PointerEventArgs e)
121 | {
122 | if (!pressed)
123 | {
124 | return;
125 | }
126 |
127 | Point newPosition = e.GetPosition(Canvas);
128 | var newPoint = _renderer.RestrictPointToCanvas(newPosition.X, newPosition.Y);
129 |
130 | _renderer.EnqueueLineSegment(currentPoint, newPoint);
131 |
132 | currentPoint = newPoint;
133 |
134 | IndicateDrawing(_appState.Nickname);
135 | }
136 |
137 | private async void OnCopyRoomNameClick(object _, RoutedEventArgs e)
138 | {
139 | await Clipboard!.SetTextAsync(_appState.Room);
140 | }
141 |
142 | private void IndicateDrawing(string nickname)
143 | {
144 | var vm = DataContext as MainWindowViewModel;
145 | vm.IndicateDrawing(nickname);
146 | }
147 |
148 | private Task Connection_Closed(Exception arg)
149 | {
150 | if (isClosing)
151 | {
152 | return Task.CompletedTask;
153 | }
154 |
155 | closeAdditionalAction = () =>
156 | {
157 | var vm = DataContext as MainWindowViewModel;
158 | if (vm.SignalRService.Connection.State == HubConnectionState.Disconnected)
159 | {
160 | var errorWindow = new ErrorWindow
161 | {
162 | DataContext = new ErrorViewModel("You got disconnected :( Please check your internet connection or try again later.", true),
163 | Topmost = true,
164 | CanResize = false
165 | };
166 | errorWindow.Show();
167 | errorWindow.Activate();
168 | }
169 | };
170 |
171 | Dispatcher.UIThread.InvokeAsync(Close);
172 |
173 | return Task.CompletedTask;
174 | }
175 |
176 | protected override void OnClosing(WindowClosingEventArgs e)
177 | {
178 | isClosing = true;
179 |
180 | var vm = DataContext as MainWindowViewModel;
181 | if (vm.SignalRService.Connection.State == HubConnectionState.Connected)
182 | {
183 | _ = vm.SignalRService.Connection.StopAsync();
184 | }
185 |
186 | _ = vm.SignalRService.Connection.DisposeAsync();
187 |
188 | var window = new LobbyWindow
189 | {
190 | DataContext = new LobbyViewModel(true),
191 | Topmost = true,
192 | CanResize = false
193 | };
194 | window.Show();
195 |
196 | closeAdditionalAction();
197 | }
198 | }
199 |
--------------------------------------------------------------------------------
/src/TeamSketch/logo.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/davidtimovski/team-sketch/c561847b593595cf76c4d39bfe3e0f2e97e4e602/src/TeamSketch/logo.ico
--------------------------------------------------------------------------------