Skip to content

Commit b3a8cd7

Browse files
feat: adding user profile image
1 parent 6fa4197 commit b3a8cd7

File tree

8 files changed

+197
-42
lines changed

8 files changed

+197
-42
lines changed

src/TwitchStreamingTools/Configuration.cs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
using System;
2-
using System.Collections.Generic;
1+
using System.Collections.Generic;
32
using System.IO;
43
using System.Linq;
54
using System.Speech.Synthesis;
@@ -22,9 +21,7 @@ public class Configuration : IConfiguration {
2221
/// <summary>
2322
/// The location of the configuration file.
2423
/// </summary>
25-
private static readonly string CONFIG_LOCATION =
26-
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "nullinside",
27-
"twitch-streaming-tools", "config.json");
24+
private static readonly string CONFIG_LOCATION = Path.Combine(Constants.SAVE_FOLDER, "config.json");
2825

2926
/// <summary>
3027
/// The singleton instance.

src/TwitchStreamingTools/Constants.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System;
12
using System.Collections.Generic;
3+
using System.IO;
24
using System.Reflection;
35

46
namespace TwitchStreamingTools;
@@ -39,6 +41,14 @@ public static class Constants {
3941
public const string DOMAIN = "nullinside.com";
4042
#endif
4143

44+
/// <summary>
45+
/// The location of saved files the application generates.
46+
/// </summary>
47+
/// <remarks>Not the install folder.</remarks>
48+
public static readonly string SAVE_FOLDER =
49+
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "nullinside",
50+
"twitch-streaming-tools");
51+
4252
/// <summary>
4353
/// A regular expression for identifying a link.
4454
/// </summary>

src/TwitchStreamingTools/Services/TwitchAccountService.cs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
using Nullinside.Api.Common.Twitch;
77

8+
using TwitchLib.Api.Helix.Models.Users.GetUsers;
9+
810
using TwitchStreamingTools.Utilities;
911

1012
namespace TwitchStreamingTools.Services;
@@ -63,12 +65,12 @@ public async Task UpdateCredentials(string bearer, string refresh, DateTime expi
6365
RefreshToken = refresh,
6466
ExpiresUtc = expires
6567
};
66-
68+
6769
var twitchApi = new TwitchApiWrapper {
6870
OAuth = oauth
6971
};
7072

71-
(string? id, string? username)? user = null;
73+
User? user = null;
7274
try {
7375
user = await twitchApi.GetUser();
7476
}
@@ -78,10 +80,10 @@ public async Task UpdateCredentials(string bearer, string refresh, DateTime expi
7880

7981
_configuration.OAuth = oauth;
8082

81-
_configuration.TwitchUsername = user?.username;
83+
_configuration.TwitchUsername = user?.Login;
8284
_configuration.WriteConfiguration();
85+
_twitchClient.TwitchUsername = user?.Login;
8386
_twitchClient.TwitchOAuthToken = bearer;
84-
_twitchClient.TwitchUsername = user?.username;
8587

8688
OnCredentialsChanged?.Invoke(oauth);
8789
await OnCheckCredentials();
@@ -108,14 +110,14 @@ private async Task OnCheckCredentials() {
108110
_timer.Stop();
109111
// Grab the value so we can check if the value changed
110112
bool credsWereValid = CredentialsAreValid;
111-
113+
112114
try {
113115
// Refresh the token
114116
await DoTokenRefreshIfNearExpiration();
115117

116118
// Make sure the new token works
117119
var twitchApi = new TwitchApiWrapper();
118-
string? username = (await twitchApi.GetUser()).username;
120+
string? username = (await twitchApi.GetUser())?.Login;
119121

120122
// Update the credentials
121123
CredentialsAreValid = !string.IsNullOrWhiteSpace(username);
@@ -131,7 +133,7 @@ private async Task OnCheckCredentials() {
131133
if (credsWereValid != CredentialsAreValid) {
132134
OnCredentialsStatusChanged?.Invoke(CredentialsAreValid);
133135
}
134-
136+
135137
_timer.Start();
136138
}
137139
}

src/TwitchStreamingTools/ViewModels/Pages/AccountViewModel.cs

Lines changed: 163 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
using System;
22
using System.Diagnostics;
3+
using System.IO;
4+
using System.Net.Http;
35
using System.Net.WebSockets;
46
using System.Reactive;
57
using System.Threading;
8+
using System.Threading.Tasks;
9+
10+
using Avalonia.Media.Imaging;
611

712
using log4net;
813

@@ -13,14 +18,33 @@
1318

1419
using ReactiveUI;
1520

21+
using TwitchLib.Api.Helix.Models.Users.GetUsers;
22+
1623
using TwitchStreamingTools.Services;
24+
using TwitchStreamingTools.Utilities;
1725

1826
namespace TwitchStreamingTools.ViewModels.Pages;
1927

2028
/// <summary>
2129
/// Handles binding your account to the application.
2230
/// </summary>
2331
public class AccountViewModel : PageViewModelBase, IDisposable {
32+
/// <summary>
33+
/// The path to the folder containing cached profile images.
34+
/// </summary>
35+
private static readonly string PROFILE_IMAGE_FOLDER = Path.Combine(Constants.SAVE_FOLDER,
36+
"twitch-profile-image-cache");
37+
38+
/// <summary>
39+
/// The template for a profile image filename.
40+
/// </summary>
41+
private static readonly string PROFILE_IMAGE_FILENAME = "twitch_profile_{0}.png";
42+
43+
/// <summary>
44+
/// The configuration.
45+
/// </summary>
46+
private readonly IConfiguration _configuration;
47+
2448
/// <summary>
2549
/// The logger.
2650
/// </summary>
@@ -36,6 +60,11 @@ public class AccountViewModel : PageViewModelBase, IDisposable {
3660
/// </summary>
3761
private bool _hasValidOAuthToken;
3862

63+
/// <summary>
64+
/// The profile image of the logged in user.
65+
/// </summary>
66+
private Bitmap? _profileImage;
67+
3968
/// <summary>
4069
/// The authenticated user's twitch username.
4170
/// </summary>
@@ -45,24 +74,35 @@ public class AccountViewModel : PageViewModelBase, IDisposable {
4574
/// Initializes a new instance of the <see cref="AccountViewModel" /> class.
4675
/// </summary>
4776
/// <param name="twitchAccountService">Manages the account OAuth information.</param>
48-
public AccountViewModel(ITwitchAccountService twitchAccountService) {
77+
/// <param name="configuration">The configuration.</param>
78+
public AccountViewModel(ITwitchAccountService twitchAccountService, IConfiguration configuration) {
4979
_twitchAccountService = twitchAccountService;
5080
_twitchAccountService.OnCredentialsStatusChanged += OnCredentialsStatusChanged;
51-
OnLaunchBrowser = ReactiveCommand.Create(LaunchBrowser);
81+
_twitchAccountService.OnCredentialsChanged += OnCredentialsChanged;
82+
_configuration = configuration;
83+
OnPerformLogin = ReactiveCommand.Create(PerformLogin);
5284
OnLogout = ReactiveCommand.Create(ClearCredentials);
5385

5486
// Set the initial state of the ui
5587
HasValidOAuthToken = _twitchAccountService.CredentialsAreValid;
5688
TwitchUsername = _twitchAccountService.TwitchUsername;
5789
}
5890

91+
/// <summary>
92+
/// The profile image of the logged in user.
93+
/// </summary>
94+
public Bitmap? ProfileImage {
95+
get => _profileImage;
96+
set => this.RaiseAndSetIfChanged(ref _profileImage, value);
97+
}
98+
5999
/// <inheritdoc />
60100
public override string IconResourceKey { get; } = "InprivateAccountRegular";
61101

62102
/// <summary>
63-
/// Called when toggling the menu open and close.
103+
/// Called when the user clicks the login button.
64104
/// </summary>
65-
public ReactiveCommand<Unit, Unit> OnLaunchBrowser { get; }
105+
public ReactiveCommand<Unit, Unit> OnPerformLogin { get; }
66106

67107
/// <summary>
68108
/// Called when logging out the current user.
@@ -92,35 +132,91 @@ public string? TwitchUsername {
92132

93133
/// <inheritdoc />
94134
public void Dispose() {
95-
OnLaunchBrowser.Dispose();
135+
OnPerformLogin.Dispose();
96136
OnLogout.Dispose();
97137
}
98138

139+
/// <summary>
140+
/// Loads the profile image when the UI loads.
141+
/// </summary>
142+
public override async void OnLoaded() {
143+
base.OnLoaded();
144+
145+
try {
146+
await LoadProfileImage();
147+
}
148+
catch (Exception ex) {
149+
_logger.Error("Failed to load profile image", ex);
150+
}
151+
}
152+
153+
/// <summary>
154+
/// Finds the profile image locally or downloads it.
155+
/// </summary>
156+
private async Task LoadProfileImage() {
157+
// Try to get the file locally.
158+
string? profileImagePath = string.Format(PROFILE_IMAGE_FILENAME, _configuration.TwitchUsername);
159+
if (File.Exists(profileImagePath)) {
160+
ProfileImage = new Bitmap(profileImagePath);
161+
return;
162+
}
163+
164+
// If we couldn't find the file, download it.
165+
profileImagePath = await DownloadUserImage();
166+
if (null == profileImagePath) {
167+
return;
168+
}
169+
170+
ProfileImage = new Bitmap(profileImagePath);
171+
}
172+
173+
/// <summary>
174+
/// Called when the credentials are changed to load the new profile image.
175+
/// </summary>
176+
/// <param name="token"></param>
177+
private async void OnCredentialsChanged(TwitchAccessToken? token) {
178+
try {
179+
if (string.IsNullOrWhiteSpace(token?.AccessToken)) {
180+
return;
181+
}
182+
183+
await LoadProfileImage();
184+
}
185+
catch (Exception ex) {
186+
_logger.Error("Failed to download user profile image", ex);
187+
}
188+
}
189+
99190
/// <summary>
100191
/// Invoked when the status of the credentials changes from the <seealso cref="ITwitchAccountService" />.
101192
/// </summary>
102193
/// <param name="valid">True if the credentials are valid, false otherwise.</param>
103194
private void OnCredentialsStatusChanged(bool valid) {
104-
if (!valid) {
105-
HasValidOAuthToken = false;
106-
TwitchUsername = null;
107-
return;
108-
}
195+
try {
196+
if (!valid) {
197+
HasValidOAuthToken = false;
198+
TwitchUsername = null;
199+
return;
200+
}
109201

110-
HasValidOAuthToken = true;
111-
TwitchUsername = _twitchAccountService.TwitchUsername;
202+
HasValidOAuthToken = true;
203+
TwitchUsername = _twitchAccountService.TwitchUsername;
204+
}
205+
catch (Exception ex) {
206+
_logger.Error("Failed to update credentials status", ex);
207+
}
112208
}
113209

114210
/// <summary>
115211
/// Launches the computer's default browser to generate an OAuth token.
116212
/// </summary>
117-
private async void LaunchBrowser() {
213+
private async void PerformLogin() {
118214
try {
119-
var token = CancellationToken.None;
120-
215+
CancellationToken token = CancellationToken.None;
216+
121217
// Create an identifier for this credential request.
122218
var guid = Guid.NewGuid();
123-
219+
124220
// Create a web socket connection to the api which will provide us with the credentials from twitch.
125221
ClientWebSocket webSocket = new();
126222
await webSocket.ConnectAsync(new Uri($"ws://{Constants.DOMAIN}/api/v1/user/twitch-login/twitch-streaming-tools/ws"), token);
@@ -138,10 +234,10 @@ private async void LaunchBrowser() {
138234
// Wait for the user to finish giving us permission on the website. Once they provide us access we will receive
139235
// a response on the web socket containing a JSON with our OAuth information.
140236
string json = await webSocket.ReceiveTextAsync(token);
141-
237+
142238
// Close the connection, both sides will be waiting to do this so we do it immediately.
143239
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed Successfully!", token);
144-
240+
145241
// Update the oauth token in the twitch account service.
146242
var oauthResp = JsonConvert.DeserializeObject<TwitchAccessToken>(json);
147243
if (null == oauthResp || null == oauthResp.AccessToken || null == oauthResp.RefreshToken || null == oauthResp.ExpiresUtc) {
@@ -162,4 +258,53 @@ private async void LaunchBrowser() {
162258
private void ClearCredentials() {
163259
_twitchAccountService.DeleteCredentials();
164260
}
261+
262+
/// <summary>
263+
/// Downloads the user's profile image and adds it to the cache.
264+
/// </summary>
265+
/// <returns>The path to the saved file.</returns>
266+
private async Task<string?> DownloadUserImage() {
267+
// The user object from the API will tell us the download link on twitch for the image.
268+
var api = new TwitchApiWrapper();
269+
if (string.IsNullOrWhiteSpace(api.OAuth?.AccessToken)) {
270+
return null;
271+
}
272+
273+
User? user = await api.GetUser();
274+
if (string.IsNullOrWhiteSpace(user?.ProfileImageUrl)) {
275+
return null;
276+
}
277+
278+
// Download the image via http.
279+
using var http = new HttpClient();
280+
byte[] imageBytes = await http.GetByteArrayAsync(user.ProfileImageUrl);
281+
282+
// If the directory doesn't exist, create it.
283+
if (!Directory.Exists(PROFILE_IMAGE_FOLDER)) {
284+
Directory.CreateDirectory(PROFILE_IMAGE_FOLDER);
285+
}
286+
287+
// I don't think twitch usernames can have non-filepath friendly characters but might as well sanitize it anyway.
288+
string filename = SanitizeFilename(string.Format(PROFILE_IMAGE_FILENAME, user.Login));
289+
string imagePath = Path.Combine(PROFILE_IMAGE_FOLDER, filename);
290+
291+
// Save to disk
292+
await File.WriteAllBytesAsync(imagePath, imageBytes);
293+
294+
// Return path to file, even though everyone already knows it.
295+
return imagePath;
296+
}
297+
298+
/// <summary>
299+
/// Removes invalid characters from the passed in string.
300+
/// </summary>
301+
/// <param name="input">The filename to sanitize.</param>
302+
/// <returns>The sanitized filename.</returns>
303+
private static string SanitizeFilename(string input) {
304+
foreach (char c in Path.GetInvalidFileNameChars()) {
305+
input = input.Replace(c, '_');
306+
}
307+
308+
return input;
309+
}
165310
}

0 commit comments

Comments
 (0)