IN-CALL pill shows meeting title from Teams' window text
Some checks failed
CI / build-and-test (push) Failing after 28s
Some checks failed
CI / build-and-test (push) Failing after 28s
The IN-CALL pill now reads 'IN CALL · Weekly Standup' (or 'IN CALL' if Teams' window doesn't expose a meeting title), so operators using auto-hide know WHICH meeting they're in without restoring the Teams window. Implementation: TeamsLauncher.GetActiveWindowTitle uses EnumWindows + GetWindowTextW to read every Teams top-level window title (hidden windows too — title bar text is accessible even with SW_HIDE), picks the longest as a heuristic for 'most informative' (Teams creates several windows per process; the call window has the meaningful title). MainViewModel.ExtractMeetingTitle strips the ' | Microsoft Teams' / ' - Microsoft Teams' suffix variations and clamps overly long titles to 50 chars with an ellipsis. 10 new unit tests for ExtractMeetingTitle covering: standard formats with both separators, bare 'Microsoft Teams' (returns empty so the pill stays at 'IN CALL'), long-title truncation, outer-whitespace trimming, unrecognized formats passing through. 169/169 tests passing.
This commit is contained in:
parent
b9147183ce
commit
7ef6b8055e
3 changed files with 149 additions and 1 deletions
|
|
@ -264,8 +264,58 @@ public static class TeamsLauncher
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern int GetWindowTextW(IntPtr hWnd, [Out] System.Text.StringBuilder lpString, int nMaxCount);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern int GetWindowTextLengthW(IntPtr hWnd);
|
||||||
|
|
||||||
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the title bar text of Teams' most-recently-used top-level
|
||||||
|
/// window, or empty string if Teams isn't running. Modern Teams puts
|
||||||
|
/// the meeting title in the window title while in a call ("Meeting with
|
||||||
|
/// Alice | Microsoft Teams"), so this is the cheapest way to surface
|
||||||
|
/// meeting context to TeamsISO's UI without burning a UIA traversal.
|
||||||
|
///
|
||||||
|
/// Includes hidden windows — operators using auto-hide still get the
|
||||||
|
/// title surfaced, which is the whole point.
|
||||||
|
/// </summary>
|
||||||
|
public static string GetActiveWindowTitle()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var teamsPids = new HashSet<uint>(
|
||||||
|
TeamsProcessNames
|
||||||
|
.SelectMany(n => Process.GetProcessesByName(n))
|
||||||
|
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
|
||||||
|
if (teamsPids.Count == 0) return string.Empty;
|
||||||
|
|
||||||
|
string longestTitle = string.Empty;
|
||||||
|
EnumWindows((hWnd, _) =>
|
||||||
|
{
|
||||||
|
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true;
|
||||||
|
GetWindowThreadProcessId(hWnd, out var pid);
|
||||||
|
if (!teamsPids.Contains(pid)) return true;
|
||||||
|
|
||||||
|
var len = GetWindowTextLengthW(hWnd);
|
||||||
|
if (len <= 0) return true;
|
||||||
|
var sb = new System.Text.StringBuilder(len + 1);
|
||||||
|
GetWindowTextW(hWnd, sb, sb.Capacity);
|
||||||
|
var title = sb.ToString();
|
||||||
|
// Teams creates a few top-level windows per process; the
|
||||||
|
// call/meeting window has the longest title (other windows
|
||||||
|
// tend to just be "Microsoft Teams"). Pick the longest one
|
||||||
|
// as a heuristic for "most informative".
|
||||||
|
if (title.Length > longestTitle.Length) longestTitle = title;
|
||||||
|
return true;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
return longestTitle;
|
||||||
|
}
|
||||||
|
catch { return string.Empty; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enumerate every visible top-level window owned by any running Teams
|
/// Enumerate every visible top-level window owned by any running Teams
|
||||||
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
|
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
|
||||||
|
|
|
||||||
|
|
@ -524,6 +524,34 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
Toast.Show($"Stopped {enabled.Length} ISO(s)");
|
Toast.Show($"Stopped {enabled.Length} ISO(s)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pull the meaningful "meeting title" out of Teams' raw window title.
|
||||||
|
/// Teams uses formats like:
|
||||||
|
/// "Weekly Standup | Microsoft Teams"
|
||||||
|
/// "Meeting with Alice | Microsoft Teams"
|
||||||
|
/// "Microsoft Teams" (no meeting, just the app)
|
||||||
|
/// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays
|
||||||
|
/// short and readable. Truncate beyond 50 chars so a long meeting
|
||||||
|
/// subject doesn't push the rest of the IN-CALL bar off screen.
|
||||||
|
/// </summary>
|
||||||
|
internal static string ExtractMeetingTitle(string windowTitle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty;
|
||||||
|
var t = windowTitle.Trim();
|
||||||
|
// Common separator patterns Teams uses across locales.
|
||||||
|
foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" })
|
||||||
|
{
|
||||||
|
var idx = t.IndexOf(sep, StringComparison.Ordinal);
|
||||||
|
if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
|
||||||
|
}
|
||||||
|
// If after stripping we're left with just "Microsoft Teams" the
|
||||||
|
// window has no meeting context — return empty so the pill stays
|
||||||
|
// at "IN CALL" without a stale title.
|
||||||
|
if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
|
||||||
|
if (t.Length > 50) t = t.Substring(0, 47) + "…";
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnStatsTick(object? sender, EventArgs e)
|
private void OnStatsTick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
foreach (var vm in Participants)
|
foreach (var vm in Participants)
|
||||||
|
|
@ -628,10 +656,13 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var inCall = TeamsControlBridge.IsInCall();
|
var inCall = TeamsControlBridge.IsInCall();
|
||||||
|
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
|
||||||
_dispatcher.InvokeAsync(() =>
|
_dispatcher.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
IsTeamsInCall = inCall;
|
IsTeamsInCall = inCall;
|
||||||
TeamsMeetingState = inCall ? "IN CALL" : "READY";
|
TeamsMeetingState = inCall
|
||||||
|
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
|
||||||
|
: "READY";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch { /* UIA flakiness shouldn't crash the stats tick */ }
|
catch { /* UIA flakiness shouldn't crash the stats tick */ }
|
||||||
|
|
|
||||||
67
src/tests/TeamsISO.App.Tests/MeetingTitleExtractionTests.cs
Normal file
67
src/tests/TeamsISO.App.Tests/MeetingTitleExtractionTests.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
using TeamsISO.App.ViewModels;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the title-stripping heuristic used by the IN-CALL bar pill.
|
||||||
|
/// Teams' raw window title is the format the user actually sees (or doesn't —
|
||||||
|
/// when auto-hide is on); we want to surface the meaningful meeting-name
|
||||||
|
/// portion without the "| Microsoft Teams" suffix bloating the pill.
|
||||||
|
/// </summary>
|
||||||
|
public class MeetingTitleExtractionTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Weekly Standup | Microsoft Teams", "Weekly Standup")]
|
||||||
|
[InlineData("Meeting with Alice | Microsoft Teams", "Meeting with Alice")]
|
||||||
|
[InlineData("Q4 Planning - Microsoft Teams", "Q4 Planning")]
|
||||||
|
[InlineData("Meeting | Teams", "Meeting")]
|
||||||
|
public void StripsTeamsSuffix(string raw, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, MainViewModel.ExtractMeetingTitle(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyInput_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
Assert.Equal(string.Empty, MainViewModel.ExtractMeetingTitle(string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Whitespace_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
Assert.Equal(string.Empty, MainViewModel.ExtractMeetingTitle(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BareAppTitle_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
// "Microsoft Teams" alone means no meeting context — pill should
|
||||||
|
// stay at plain "IN CALL" rather than appending a meaningless title.
|
||||||
|
Assert.Equal(string.Empty, MainViewModel.ExtractMeetingTitle("Microsoft Teams"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LongTitle_GetsTruncated()
|
||||||
|
{
|
||||||
|
var long_ = new string('A', 100) + " | Microsoft Teams";
|
||||||
|
var result = MainViewModel.ExtractMeetingTitle(long_);
|
||||||
|
Assert.True(result.Length <= 50);
|
||||||
|
Assert.EndsWith("…", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OuterWhitespaceIsTrimmed()
|
||||||
|
{
|
||||||
|
// Real Teams uses single-space format " | Microsoft Teams"; we only
|
||||||
|
// promise to trim outer whitespace, not normalize internal padding.
|
||||||
|
Assert.Equal("Project sync", MainViewModel.ExtractMeetingTitle(" Project sync | Microsoft Teams "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TitleWithoutSeparator_PassesThrough()
|
||||||
|
{
|
||||||
|
// If Teams emits an unrecognized format, return it as-is (clamped to 50).
|
||||||
|
Assert.Equal("Quick chat", MainViewModel.ExtractMeetingTitle("Quick chat"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue