Merge pull request #1 from namecallfilter/sync/upstream-frosty
fix chat parsing and Flutter 3.44 release checks
This commit is contained in:
commit
9b7decab98
13 changed files with 191 additions and 37 deletions
2
.fvmrc
2
.fvmrc
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"flutter": "3.38.9"
|
||||
"flutter": "3.44.1"
|
||||
}
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.38.9'
|
||||
flutter-version: '3.44.1'
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
|
|
@ -45,5 +45,7 @@ jobs:
|
|||
- name: Test
|
||||
run: flutter test
|
||||
|
||||
- name: Build debug APK
|
||||
run: flutter build apk --debug --dart-define=CLIENT_ID=dummy --dart-define=SECRET=dummy
|
||||
- name: Build release APK
|
||||
run: flutter build apk --release --dart-define=CLIENT_ID=dummy --dart-define=SECRET=dummy
|
||||
env:
|
||||
ALLOW_DEBUG_SIGNED_RELEASE: '1'
|
||||
|
|
|
|||
2
.github/workflows/gh-release.yml
vendored
2
.github/workflows/gh-release.yml
vendored
|
|
@ -44,7 +44,7 @@ jobs:
|
|||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.38.9'
|
||||
flutter-version: '3.44.1'
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
|
|
|
|||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.38.9"
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.44.1"
|
||||
}
|
||||
|
|
@ -50,4 +50,6 @@ dart run build_runner build --delete-conflicting-outputs # Same, but
|
|||
|
||||
## Commits
|
||||
|
||||
Lowercase, no prefixes (e.g., `fix landscape bottom padding in chat bottom bar`). Keep commits tightly scoped.
|
||||
Conventional commits: `type(scope): description` — lowercase, no trailing period, tightly scoped (e.g., `fix(chat): correct landscape bottom padding in bottom bar`). Append `!` before the `:` for breaking changes (e.g., `feat(api)!: drop legacy auth flow`).
|
||||
|
||||
Common types: `feat`, `fix`, `chore`, `refactor`, `docs`, `test`, `perf`, `style`. Scope is the affected area (e.g., `chat`, `video`, `api`).
|
||||
|
|
|
|||
|
|
@ -47,7 +47,16 @@ android {
|
|||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
// Use the real upload key when key.properties is present (release
|
||||
// workflows create it from secrets). Allow debug signing only when a CI
|
||||
// compile-only check explicitly opts in via ALLOW_DEBUG_SIGNED_RELEASE;
|
||||
// otherwise fail loudly so a real release can never be silently
|
||||
// debug-signed if the keystore step is ever missing.
|
||||
signingConfig = when {
|
||||
keystorePropertiesFile.exists() -> signingConfigs.getByName("release")
|
||||
System.getenv("ALLOW_DEBUG_SIGNED_RELEASE") != null -> signingConfigs.getByName("debug")
|
||||
else -> error("key.properties missing - set ALLOW_DEBUG_SIGNED_RELEASE=1 for compile-only builds")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -959,22 +959,25 @@ class IRCMessage {
|
|||
// Obtain the index to break off the tags.
|
||||
final tagAndIrcMessageDivider = whole.indexOf(' ');
|
||||
|
||||
// Get the tags substring and escape characters.
|
||||
// IRC messages escape spaces with \s.
|
||||
final tags = whole
|
||||
.substring(1, tagAndIrcMessageDivider)
|
||||
.replaceAll('\\s', ' ');
|
||||
|
||||
// Next, parse and map the tags.
|
||||
// Parse the IRCv3 tags into a map. The tags section is everything between
|
||||
// the leading '@' and the first space. We split on the *raw* string: a
|
||||
// literal ';' is always a separator because escaped semicolons inside a
|
||||
// value are encoded as `\:` (unescaped per-value below).
|
||||
final mappedTags = <String, String>{};
|
||||
final rawTags = whole.substring(1, tagAndIrcMessageDivider);
|
||||
|
||||
// Loop through each tag and store their key value pairs into the map.
|
||||
for (final tag in tags.split(';')) {
|
||||
// Skip if the tag has no value.
|
||||
if (tag.endsWith('=')) continue;
|
||||
for (final tag in rawTags.split(';')) {
|
||||
// Split on the first '=' only; IRCv3 tag values may contain '='.
|
||||
final equalsIndex = tag.indexOf('=');
|
||||
|
||||
final tagSplit = tag.split('=');
|
||||
mappedTags[tagSplit[0]] = tagSplit[1];
|
||||
// Skip valueless tags. Per the IRCv3 spec a missing value (bare `key`)
|
||||
// and an empty value (`key=`) are equivalent; live Twitch sends the
|
||||
// latter, the recent-messages API the former.
|
||||
if (equalsIndex == -1 || equalsIndex == tag.length - 1) continue;
|
||||
|
||||
mappedTags[tag.substring(0, equalsIndex)] = _unescapeTagValue(
|
||||
tag.substring(equalsIndex + 1),
|
||||
);
|
||||
}
|
||||
|
||||
// Now we'll parse the message itself.
|
||||
|
|
@ -990,9 +993,19 @@ class IRCMessage {
|
|||
|
||||
// If the username exists, set it.
|
||||
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
|
||||
final String? user = splitMessage[0] == _twitchIrcServer
|
||||
? mappedTags['login']
|
||||
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
|
||||
final prefix = splitMessage[0];
|
||||
final String? user;
|
||||
if (prefix == _twitchIrcServer) {
|
||||
user = mappedTags['login'];
|
||||
} else {
|
||||
// Normal prefixes are `nick!user@host`. Fall back to the login tag (or
|
||||
// null) if the expected `!` is absent so an unexpected prefix shape can
|
||||
// never throw a RangeError and take down the whole message.
|
||||
final bangIndex = prefix.indexOf('!');
|
||||
user = bangIndex == -1
|
||||
? mappedTags['login']
|
||||
: prefix.substring(0, bangIndex);
|
||||
}
|
||||
|
||||
// If there is an associated message, set it.
|
||||
String? message;
|
||||
|
|
@ -1153,6 +1166,50 @@ class IRCMessage {
|
|||
);
|
||||
}
|
||||
|
||||
/// Unescapes an IRCv3 message-tag value.
|
||||
///
|
||||
/// The spec defines these sequences: `\:` to `;`, `\s` to space, `\\` to `\`,
|
||||
/// `\r` to CR, `\n` to LF. Any other `\x` collapses to `x`, and a lone trailing
|
||||
/// backslash is dropped. Done in a single pass so sequences can't be
|
||||
/// double-unescaped (e.g. `\\s` correctly yields `\s`, not a space).
|
||||
static String _unescapeTagValue(String value) {
|
||||
// Fast path: the vast majority of tag values contain no escape sequences.
|
||||
if (!value.contains('\\')) return value;
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < value.length; i++) {
|
||||
if (value[i] != '\\') {
|
||||
buffer.write(value[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A trailing backslash with nothing after it is dropped.
|
||||
if (i + 1 == value.length) break;
|
||||
|
||||
switch (value[i + 1]) {
|
||||
case ':':
|
||||
buffer.write(';');
|
||||
break;
|
||||
case 's':
|
||||
buffer.write(' ');
|
||||
break;
|
||||
case '\\':
|
||||
buffer.write('\\');
|
||||
break;
|
||||
case 'r':
|
||||
buffer.write('\r');
|
||||
break;
|
||||
case 'n':
|
||||
buffer.write('\n');
|
||||
break;
|
||||
default:
|
||||
buffer.write(value[i + 1]);
|
||||
}
|
||||
i++; // Skip the escaped character we just consumed.
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
factory IRCMessage.createNotice({
|
||||
required String message,
|
||||
VoidCallback? actionCallback,
|
||||
|
|
|
|||
|
|
@ -290,6 +290,18 @@ abstract class ChatAssetsStoreBase with Store {
|
|||
await Future.wait(futures);
|
||||
}
|
||||
|
||||
/// Whether any shared chat participant assets have been merged in.
|
||||
bool get hasLoadedSharedChatAssets => _loadedSharedChannelIds.isNotEmpty;
|
||||
|
||||
/// Clears tracking of merged shared chat participant assets so that leaving
|
||||
/// and later re-entering a shared chat session re-fetches cleanly. The caller
|
||||
/// must re-fetch this channel's own assets afterwards (which replaces the
|
||||
/// merged emote/badge maps). Must run within a MobX action.
|
||||
void resetSharedChatAssets() {
|
||||
_loadedSharedChannelIds.clear();
|
||||
sharedSevenTvSetIds.clear();
|
||||
}
|
||||
|
||||
/// Load and cache shared chat participants for a broadcaster.
|
||||
/// Returns the list of new participant broadcaster IDs (deduped).
|
||||
@action
|
||||
|
|
|
|||
|
|
@ -518,10 +518,19 @@ abstract class ChatStoreBase with Store {
|
|||
for (final message in data.trimRight().split('\r\n')) {
|
||||
// debugPrint('$message\n');
|
||||
if (message.startsWith('@')) {
|
||||
final parsedIRCMessage = IRCMessage.fromString(
|
||||
message,
|
||||
userLogin: auth.user.details?.login,
|
||||
);
|
||||
// Parse defensively: a single malformed message must never abort the
|
||||
// loop, or one bad frame would drop every other message batched with
|
||||
// it (e.g. an entire recent-messages backlog). Skip and keep going.
|
||||
final IRCMessage parsedIRCMessage;
|
||||
try {
|
||||
parsedIRCMessage = IRCMessage.fromString(
|
||||
message,
|
||||
userLogin: auth.user.details?.login,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to parse IRC message: $e');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedIRCMessage.user != null) {
|
||||
chatDetailsStore.chatUsers.add(parsedIRCMessage.user!);
|
||||
|
|
@ -601,6 +610,16 @@ abstract class ChatStoreBase with Store {
|
|||
showFFZEmotes: settings.showFFZEmotes,
|
||||
showFFZBadges: settings.showFFZBadges,
|
||||
);
|
||||
} else if (wasShared &&
|
||||
!_isInSharedChatMode &&
|
||||
assetsStore.hasLoadedSharedChatAssets) {
|
||||
// On leaving shared chat, drop the participants' merged emotes and
|
||||
// badges and restore just this channel's assets; otherwise the
|
||||
// other streamer's emote set and badges linger (#522). Guarded on
|
||||
// having actually loaded shared assets so a flapping tag can't
|
||||
// trigger redundant refetches.
|
||||
assetsStore.resetSharedChatAssets();
|
||||
getAssets();
|
||||
}
|
||||
messageBuffer.add(parsedIRCMessage);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -622,6 +622,8 @@ abstract class ChatTabsStoreBase with Store {
|
|||
// Primary tab (index 0) cannot be moved, and nothing can move before it
|
||||
if (oldIndex == 0 || newIndex == 0) return;
|
||||
|
||||
// onReorderItem already adjusts newIndex for the removed item, so no
|
||||
// manual off-by-one correction is needed here.
|
||||
if (oldIndex == newIndex) return;
|
||||
|
||||
final tab = _tabs.removeAt(oldIndex);
|
||||
|
|
|
|||
|
|
@ -141,16 +141,9 @@ class ChatTabs extends StatelessWidget {
|
|||
left: 12,
|
||||
right: showMerge ? 32 : 12,
|
||||
),
|
||||
// ignore: deprecated_member_use
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
HapticFeedback.lightImpact();
|
||||
final adjustedNewIndex = oldIndex < newIndex
|
||||
? newIndex - 1
|
||||
: newIndex;
|
||||
chatTabsStore.reorderTab(
|
||||
oldIndex,
|
||||
adjustedNewIndex,
|
||||
);
|
||||
chatTabsStore.reorderTab(oldIndex, newIndex);
|
||||
},
|
||||
proxyDecorator: (child, index, animation) {
|
||||
return Material(
|
||||
|
|
|
|||
23
test/fixtures/irc_messages.dart
vendored
23
test/fixtures/irc_messages.dart
vendored
|
|
@ -9,6 +9,19 @@ const basicPrivmsg =
|
|||
'tmi-sent-ts=1234567890123;turbo=0;user-id=67890;user-type= '
|
||||
':testuser!testuser@testuser.tmi.twitch.tv PRIVMSG #channel :Hello World';
|
||||
|
||||
/// Exercises the full IRCv3 tag escape set plus a value that itself contains
|
||||
/// '='. Uses a raw string so the backslashes are literal wire bytes.
|
||||
const messageWithEscapedTagChars =
|
||||
r'@a-space=one\stwo;a-semi=left\:right;a-slash=a\\b;a-equals=k=v;'
|
||||
r'a-double=x\\sy;display-name=EscapeAll;mod=0 '
|
||||
r':escapeall!escapeall@escapeall.tmi.twitch.tv PRIVMSG #channel :hi';
|
||||
|
||||
/// A message whose prefix is not the usual `nick!user@host` form (no '!').
|
||||
/// The parser should fall back to the login tag instead of throwing.
|
||||
const messageNoBangPrefix =
|
||||
'@login=fallbackuser;mod=0;id=nobang-1 '
|
||||
':some.server.example PRIVMSG #channel :server style';
|
||||
|
||||
/// A chat message with emotes in the tags
|
||||
/// Note: emote positions are 0-indexed byte positions in the message
|
||||
/// "Kappa LUL" - Kappa is at 0-4, LUL is at 6-8
|
||||
|
|
@ -138,6 +151,16 @@ const historicalMessage =
|
|||
'tmi-sent-ts=1234567890123;turbo=0;user-id=12121;user-type= '
|
||||
':histuser!histuser@histuser.tmi.twitch.tv PRIVMSG #channel :Historical message';
|
||||
|
||||
/// A historical message as serialized by the recent-messages API, which emits
|
||||
/// empty tags as bare keys (e.g. `emotes`, `flags`) instead of the `key=` form
|
||||
/// that live Twitch IRC uses.
|
||||
const historicalMessageBareTags =
|
||||
'@badges=raging-wolf-helm/1;emotes;first-msg=0;tmi-sent-ts=1234567890123;'
|
||||
'flags;mod=0;returning-chatter=0;rm-received-ts=1234567890456;color=#1E90FF;'
|
||||
'id=hist-bare-123;room-id=12345;historical=1;user-type;turbo=0;subscriber=0;'
|
||||
'badge-info;display-name=BareUser;user-id=18181 '
|
||||
':bareuser!bareuser@bareuser.tmi.twitch.tv PRIVMSG #channel :Bare tag message';
|
||||
|
||||
/// A message from a shared chat session (source room different from current)
|
||||
const sharedChatMessage =
|
||||
'@badge-info=;badges=;color=#00BFFF;display-name=SharedUser;emotes=;'
|
||||
|
|
|
|||
|
|
@ -27,6 +27,27 @@ void main() {
|
|||
expect(msg.message, 'test message');
|
||||
});
|
||||
|
||||
test('unescapes the full IRCv3 tag escape set', () {
|
||||
final msg = IRCMessage.fromString(messageWithEscapedTagChars);
|
||||
|
||||
expect(msg.tags['a-space'], 'one two');
|
||||
expect(msg.tags['a-semi'], 'left;right');
|
||||
expect(msg.tags['a-slash'], r'a\b');
|
||||
// Only the first '=' splits key/value; later '=' stays in the value.
|
||||
expect(msg.tags['a-equals'], 'k=v');
|
||||
// `\\` consumes both backslashes; the following 's' is a literal letter,
|
||||
// not a `\s` space escape (single-pass unescaping).
|
||||
expect(msg.tags['a-double'], r'x\sy');
|
||||
});
|
||||
|
||||
test('falls back to login tag when prefix has no "!"', () {
|
||||
// An unexpected prefix shape must not throw and drop the message.
|
||||
final msg = IRCMessage.fromString(messageNoBangPrefix);
|
||||
|
||||
expect(msg.user, 'fallbackuser');
|
||||
expect(msg.message, 'server style');
|
||||
});
|
||||
|
||||
test('handles message with no color tag', () {
|
||||
final msg = IRCMessage.fromString(messageNoColor);
|
||||
|
||||
|
|
@ -223,6 +244,20 @@ void main() {
|
|||
expect(msg.tags['historical'], '1');
|
||||
});
|
||||
|
||||
test('parses historical message with bare (valueless) tags', () {
|
||||
// The recent-messages API serializes empty tags as bare keys (e.g.
|
||||
// `emotes`, `flags`) instead of `key=`. This previously threw a
|
||||
// RangeError, silently dropping all recent messages.
|
||||
final msg = IRCMessage.fromString(historicalMessageBareTags);
|
||||
|
||||
expect(msg.tags['historical'], '1');
|
||||
expect(msg.tags['display-name'], 'BareUser');
|
||||
expect(msg.message, 'Bare tag message');
|
||||
// Bare tags should be absent from the map, not crash parsing.
|
||||
expect(msg.tags.containsKey('emotes'), isFalse);
|
||||
expect(msg.tags.containsKey('flags'), isFalse);
|
||||
});
|
||||
|
||||
test('parses shared chat message with source room', () {
|
||||
final msg = IRCMessage.fromString(sharedChatMessage);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue