Merge pull request #98 from cogwheel0/feat/chat-archive-assistant-variants
feat(chat): regenerate variants and support
This commit is contained in:
@@ -30,12 +30,40 @@ sealed class ChatMessage with _$ChatMessage {
|
||||
@Default(<ChatSourceReference>[])
|
||||
List<ChatSourceReference> sources,
|
||||
Map<String, dynamic>? usage,
|
||||
// Previous generated versions of this assistant message (OpenWebUI-style)
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@Default(<ChatMessageVersion>[])
|
||||
List<ChatMessageVersion> versions,
|
||||
}) = _ChatMessage;
|
||||
|
||||
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
|
||||
_$ChatMessageFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ChatMessageVersion with _$ChatMessageVersion {
|
||||
const factory ChatMessageVersion({
|
||||
required String id,
|
||||
required String content,
|
||||
required DateTime timestamp,
|
||||
String? model,
|
||||
List<Map<String, dynamic>>? files,
|
||||
@JsonKey(
|
||||
name: 'sources',
|
||||
fromJson: _sourceRefsFromJson,
|
||||
toJson: _sourceRefsToJson,
|
||||
)
|
||||
@Default(<ChatSourceReference>[])
|
||||
List<ChatSourceReference> sources,
|
||||
@Default(<String>[]) List<String> followUps,
|
||||
@Default(<ChatCodeExecution>[]) List<ChatCodeExecution> codeExecutions,
|
||||
Map<String, dynamic>? usage,
|
||||
}) = _ChatMessageVersion;
|
||||
|
||||
factory ChatMessageVersion.fromJson(Map<String, dynamic> json) =>
|
||||
_$ChatMessageVersionFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ChatStatusUpdate with _$ChatStatusUpdate {
|
||||
const factory ChatStatusUpdate({
|
||||
|
||||
@@ -879,10 +879,59 @@ class ApiService {
|
||||
}
|
||||
|
||||
// Default path: parse message as-is
|
||||
final message = _parseOpenWebUIMessage(
|
||||
msgData,
|
||||
historyMsg: historyMsg,
|
||||
);
|
||||
var message = _parseOpenWebUIMessage(msgData, historyMsg: historyMsg);
|
||||
|
||||
// Attach server-persisted variants (siblings) as versions for assistant
|
||||
if (message.role == 'assistant' && historyMessagesMap != null) {
|
||||
try {
|
||||
final parentId = historyMsg?['parentId']?.toString();
|
||||
if (parentId != null && parentId.isNotEmpty) {
|
||||
final parent =
|
||||
historyMessagesMap[parentId] as Map<String, dynamic>?;
|
||||
final children = parent != null && parent['childrenIds'] is List
|
||||
? (parent['childrenIds'] as List)
|
||||
.map((e) => e.toString())
|
||||
.toList()
|
||||
: const <String>[];
|
||||
final versions = <ChatMessageVersion>[];
|
||||
|
||||
for (final cid in children) {
|
||||
if (cid == message.id) continue; // skip current assistant
|
||||
final sibling = historyMessagesMap[cid];
|
||||
if (sibling is Map<String, dynamic>) {
|
||||
final role = (sibling['role'] ?? '').toString();
|
||||
if (role != 'assistant') continue;
|
||||
// Build a ChatMessage from sibling for consistent parsing
|
||||
final siblingData = Map<String, dynamic>.from(sibling);
|
||||
siblingData['id'] = cid;
|
||||
final parsed = _parseOpenWebUIMessage(
|
||||
siblingData,
|
||||
historyMsg: sibling,
|
||||
);
|
||||
versions.add(
|
||||
ChatMessageVersion(
|
||||
id: parsed.id,
|
||||
content: parsed.content,
|
||||
timestamp: parsed.timestamp,
|
||||
model: parsed.model,
|
||||
files: parsed.files,
|
||||
sources: parsed.sources,
|
||||
followUps: parsed.followUps,
|
||||
codeExecutions: parsed.codeExecutions,
|
||||
usage: parsed.usage,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (versions.isNotEmpty) {
|
||||
message = message.copyWith(versions: versions);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort: ignore variants if parsing fails
|
||||
}
|
||||
}
|
||||
messages.add(message);
|
||||
if (_traceFullChatParsing) {
|
||||
DebugLogger.log(
|
||||
@@ -1412,14 +1461,19 @@ class ApiService {
|
||||
final List<Map<String, dynamic>> messagesArray = [];
|
||||
String? currentId;
|
||||
String? previousId;
|
||||
|
||||
String? lastUserId;
|
||||
for (final msg in messages) {
|
||||
final messageId = msg.id;
|
||||
|
||||
// Choose parent id (branch assistants from last user)
|
||||
final parentId = msg.role == 'assistant'
|
||||
? (lastUserId ?? previousId)
|
||||
: previousId;
|
||||
|
||||
// Build message for history.messages map
|
||||
messagesMap[messageId] = {
|
||||
'id': messageId,
|
||||
'parentId': previousId,
|
||||
'parentId': parentId,
|
||||
'childrenIds': [],
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
@@ -1432,14 +1486,14 @@ class ApiService {
|
||||
};
|
||||
|
||||
// Update parent's childrenIds if there's a previous message
|
||||
if (previousId != null && messagesMap.containsKey(previousId)) {
|
||||
(messagesMap[previousId]['childrenIds'] as List).add(messageId);
|
||||
if (parentId != null && messagesMap.containsKey(parentId)) {
|
||||
(messagesMap[parentId]['childrenIds'] as List).add(messageId);
|
||||
}
|
||||
|
||||
// Build message for messages array
|
||||
messagesArray.add({
|
||||
'id': messageId,
|
||||
'parentId': previousId,
|
||||
'parentId': parentId,
|
||||
'childrenIds': [],
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
@@ -1453,6 +1507,9 @@ class ApiService {
|
||||
|
||||
previousId = messageId;
|
||||
currentId = messageId;
|
||||
if (msg.role == 'user') {
|
||||
lastUserId = messageId;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the chat data structure matching OpenWebUI format exactly
|
||||
@@ -1509,6 +1566,7 @@ class ApiService {
|
||||
final List<Map<String, dynamic>> messagesArray = [];
|
||||
String? currentId;
|
||||
String? previousId;
|
||||
String? lastUserId;
|
||||
|
||||
for (final msg in messages) {
|
||||
final messageId = msg.id;
|
||||
@@ -1517,9 +1575,19 @@ class ApiService {
|
||||
// The msg.files array already contains all attachments in the correct format
|
||||
final sanitizedFiles = _sanitizeFilesForWebUI(msg.files);
|
||||
|
||||
// Determine parent id: allow explicit parent override via metadata
|
||||
final explicitParent = msg.metadata != null
|
||||
? (msg.metadata!['parentId']?.toString())
|
||||
: null;
|
||||
// For assistant messages, branch from the last user (OpenWebUI-style)
|
||||
final fallbackParent = msg.role == 'assistant'
|
||||
? (lastUserId ?? previousId)
|
||||
: previousId;
|
||||
final parentId = explicitParent ?? fallbackParent;
|
||||
|
||||
messagesMap[messageId] = {
|
||||
'id': messageId,
|
||||
'parentId': previousId,
|
||||
'parentId': parentId,
|
||||
'childrenIds': <String>[],
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
@@ -1536,8 +1604,8 @@ class ApiService {
|
||||
};
|
||||
|
||||
// Update parent's childrenIds
|
||||
if (previousId != null && messagesMap.containsKey(previousId)) {
|
||||
(messagesMap[previousId]['childrenIds'] as List).add(messageId);
|
||||
if (parentId != null && messagesMap.containsKey(parentId)) {
|
||||
(messagesMap[parentId]['childrenIds'] as List).add(messageId);
|
||||
}
|
||||
|
||||
// Use the same properly formatted files array for messages array
|
||||
@@ -1545,7 +1613,7 @@ class ApiService {
|
||||
|
||||
messagesArray.add({
|
||||
'id': messageId,
|
||||
'parentId': previousId,
|
||||
'parentId': parentId,
|
||||
'childrenIds': [],
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
@@ -1562,6 +1630,37 @@ class ApiService {
|
||||
});
|
||||
|
||||
previousId = messageId;
|
||||
if (msg.role == 'user') {
|
||||
lastUserId = messageId;
|
||||
}
|
||||
|
||||
// Server-side persistence of assistant versions (OpenWebUI-style)
|
||||
if (msg.role == 'assistant' && (msg.versions.isNotEmpty)) {
|
||||
final parentForVersions = explicitParent ?? lastUserId ?? previousId;
|
||||
for (final ver in msg.versions) {
|
||||
final vId = ver.id;
|
||||
// Only add if not already present
|
||||
if (!messagesMap.containsKey(vId)) {
|
||||
messagesMap[vId] = {
|
||||
'id': vId,
|
||||
'parentId': parentForVersions,
|
||||
'childrenIds': <String>[],
|
||||
'role': 'assistant',
|
||||
'content': ver.content,
|
||||
'timestamp': ver.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
if (ver.model != null) 'model': ver.model,
|
||||
if (ver.model != null) 'modelName': ver.model,
|
||||
'modelIdx': 0,
|
||||
'done': true,
|
||||
if (ver.files != null) 'files': _sanitizeFilesForWebUI(ver.files),
|
||||
};
|
||||
// Link into parent (parentForVersions is always non-null here)
|
||||
if (messagesMap.containsKey(parentForVersions)) {
|
||||
(messagesMap[parentForVersions]['childrenIds'] as List).add(vId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
currentId = messageId;
|
||||
}
|
||||
|
||||
|
||||
@@ -494,6 +494,45 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
|
||||
state = next;
|
||||
}
|
||||
|
||||
// Archive the last assistant message's current content as a previous version
|
||||
// and clear it to prepare for regeneration, keeping the same message id.
|
||||
void archiveLastAssistantAsVersion() {
|
||||
if (state.isEmpty) return;
|
||||
final last = state.last;
|
||||
if (last.role != 'assistant') return;
|
||||
// Do not archive if it's already streaming (nothing final to archive)
|
||||
if (last.isStreaming) return;
|
||||
|
||||
final snapshot = ChatMessageVersion(
|
||||
id: last.id,
|
||||
content: last.content,
|
||||
timestamp: last.timestamp,
|
||||
model: last.model,
|
||||
files: last.files == null
|
||||
? null
|
||||
: List<Map<String, dynamic>>.from(last.files!),
|
||||
sources: List<ChatSourceReference>.from(last.sources),
|
||||
followUps: List<String>.from(last.followUps),
|
||||
codeExecutions: List<ChatCodeExecution>.from(last.codeExecutions),
|
||||
usage: last.usage == null ? null : Map<String, dynamic>.from(last.usage!),
|
||||
);
|
||||
|
||||
final updated = last.copyWith(
|
||||
// Start a fresh stream for the new generation
|
||||
isStreaming: true,
|
||||
content: '',
|
||||
files: null,
|
||||
followUps: const [],
|
||||
codeExecutions: const [],
|
||||
sources: const [],
|
||||
usage: null,
|
||||
versions: [...last.versions, snapshot],
|
||||
);
|
||||
|
||||
state = [...state.sublist(0, state.length - 1), updated];
|
||||
_touchStreamingActivity();
|
||||
}
|
||||
|
||||
void appendStatusUpdate(String messageId, ChatStatusUpdate update) {
|
||||
final withTimestamp = update.occurredAt == null
|
||||
? update.copyWith(occurredAt: DateTime.now())
|
||||
@@ -644,10 +683,38 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
|
||||
final finalized = _finalizeFormatter(lastMessage.id, lastMessage.content);
|
||||
final cleaned = _stripStreamingPlaceholders(finalized);
|
||||
|
||||
state = [
|
||||
...state.sublist(0, state.length - 1),
|
||||
lastMessage.copyWith(isStreaming: false, content: cleaned),
|
||||
];
|
||||
var updatedLast = lastMessage.copyWith(
|
||||
isStreaming: false,
|
||||
content: cleaned,
|
||||
);
|
||||
|
||||
// Fallback: if there is an immediately previous assistant message
|
||||
// marked as an archived variant and we have no versions yet, attach it
|
||||
// as a version so the UI shows a switcher.
|
||||
if (state.length >= 2 && updatedLast.versions.isEmpty) {
|
||||
final prev = state[state.length - 2];
|
||||
final isArchivedAssistant =
|
||||
prev.role == 'assistant' &&
|
||||
(prev.metadata?['archivedVariant'] == true);
|
||||
if (isArchivedAssistant) {
|
||||
final snapshot = ChatMessageVersion(
|
||||
id: prev.id,
|
||||
content: prev.content,
|
||||
timestamp: prev.timestamp,
|
||||
model: prev.model,
|
||||
files: prev.files,
|
||||
sources: prev.sources,
|
||||
followUps: prev.followUps,
|
||||
codeExecutions: prev.codeExecutions,
|
||||
usage: prev.usage,
|
||||
);
|
||||
updatedLast = updatedLast.copyWith(
|
||||
versions: [...updatedLast.versions, snapshot],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state = [...state.sublist(0, state.length - 1), updatedLast];
|
||||
_messageStream = null;
|
||||
_stopRemoteTaskMonitor();
|
||||
|
||||
@@ -1014,8 +1081,9 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
||||
Future<void> regenerateMessage(
|
||||
dynamic ref,
|
||||
String userMessageContent,
|
||||
List<String>? attachments,
|
||||
) async {
|
||||
List<String>? attachments, [
|
||||
String? existingAssistantId,
|
||||
]) async {
|
||||
final reviewerMode = ref.read(reviewerModeProvider);
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final selectedModel = ref.read(selectedModelProvider);
|
||||
@@ -1135,13 +1203,42 @@ Future<void> regenerateMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-seed assistant skeleton and persist chain
|
||||
// Pre-seed assistant skeleton and persist chain; always use a new id so
|
||||
// server history can branch like OpenWebUI.
|
||||
final String assistantMessageId = await _preseedAssistantAndPersist(
|
||||
ref,
|
||||
existingAssistantId: null,
|
||||
modelId: selectedModel.id,
|
||||
systemPrompt: effectiveSystemPrompt,
|
||||
);
|
||||
|
||||
// Attach previous assistant as a version snapshot to the new assistant
|
||||
try {
|
||||
final msgs = ref.read(chatMessagesProvider);
|
||||
if (msgs.length >= 2) {
|
||||
final prev = msgs[msgs.length - 2];
|
||||
final last = msgs.last;
|
||||
if (prev.role == 'assistant' && last.id == assistantMessageId) {
|
||||
final snapshot = ChatMessageVersion(
|
||||
id: prev.id,
|
||||
content: prev.content,
|
||||
timestamp: prev.timestamp,
|
||||
model: prev.model,
|
||||
files: prev.files,
|
||||
sources: prev.sources,
|
||||
followUps: prev.followUps,
|
||||
codeExecutions: prev.codeExecutions,
|
||||
usage: prev.usage,
|
||||
);
|
||||
ref
|
||||
.read(chatMessagesProvider.notifier)
|
||||
.updateLastMessageWithFunction(
|
||||
(m) => m.copyWith(versions: [...m.versions, snapshot]),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Feature toggles
|
||||
final webSearchEnabled =
|
||||
ref.read(webSearchEnabledProvider) &&
|
||||
@@ -2223,8 +2320,16 @@ final regenerateLastMessageProvider = Provider<Future<void> Function()>((ref) {
|
||||
|
||||
if (lastUserMessage == null) return;
|
||||
|
||||
// Remove last assistant message
|
||||
ref.read(chatMessagesProvider.notifier).removeLastMessage();
|
||||
// Mark previous assistant as an archived variant so UI can hide it
|
||||
final notifier = ref.read(chatMessagesProvider.notifier);
|
||||
if (lastAssistantMessage != null) {
|
||||
notifier.updateLastMessageWithFunction((m) {
|
||||
final meta = Map<String, dynamic>.from(m.metadata ?? const {});
|
||||
meta['archivedVariant'] = true;
|
||||
// Keep content/files intact for server persistence
|
||||
return m.copyWith(metadata: meta, isStreaming: false);
|
||||
});
|
||||
}
|
||||
|
||||
// If previous assistant was image-only or had images, regenerate images instead of text
|
||||
if (lastAssistantHadImages) {
|
||||
|
||||
@@ -902,6 +902,13 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// Hide archived assistant variants in the linear view
|
||||
final isArchivedVariant =
|
||||
!isUser && (message.metadata?['archivedVariant'] == true);
|
||||
if (isArchivedVariant) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final showFollowUps =
|
||||
!isUser && !hasUserBubbleBelow && !hasAssistantBubbleBelow;
|
||||
|
||||
@@ -990,8 +997,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the assistant message we want to regenerate
|
||||
ref.read(chatMessagesProvider.notifier).removeLastMessage();
|
||||
// Mark previous assistant as archived for UI; keep it for server history
|
||||
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction((
|
||||
m,
|
||||
) {
|
||||
final meta = Map<String, dynamic>.from(m.metadata ?? const {});
|
||||
meta['archivedVariant'] = true;
|
||||
return m.copyWith(metadata: meta, isStreaming: false);
|
||||
});
|
||||
|
||||
// Regenerate response for the previous user message (without duplicating it)
|
||||
final userMessage = messages[messageIndex - 1];
|
||||
|
||||
@@ -70,6 +70,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
bool _allowTypingIndicator = false;
|
||||
Timer? _typingGateTimer;
|
||||
String _ttsPlainText = '';
|
||||
// Active version index (-1 means current/live content)
|
||||
int _activeVersionIndex = -1;
|
||||
// press state handled by shared ChatActionButton
|
||||
|
||||
Future<void> _handleFollowUpTap(String suggestion) async {
|
||||
@@ -140,7 +142,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
}
|
||||
|
||||
void _reparseSections() {
|
||||
final raw0 = widget.message.content ?? '';
|
||||
final raw0 = _activeVersionIndex >= 0
|
||||
? (widget.message.versions[_activeVersionIndex].content as String?) ??
|
||||
''
|
||||
: widget.message.content ?? '';
|
||||
// Strip any leftover placeholders from content before parsing
|
||||
const ti = '[TYPING_INDICATOR]';
|
||||
const searchBanner = '🔍 Searching the web...';
|
||||
@@ -633,6 +638,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
widget.showFollowUps &&
|
||||
widget.message.followUps.isNotEmpty &&
|
||||
!widget.isStreaming;
|
||||
final bool showingVersion = _activeVersionIndex >= 0;
|
||||
final activeFiles = showingVersion
|
||||
? widget.message.versions[_activeVersionIndex].files
|
||||
: widget.message.files;
|
||||
final hasSources = widget.message.sources.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
@@ -657,8 +666,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Display attachments - prioritize files array over attachmentIds to avoid duplication
|
||||
if (widget.message.files != null &&
|
||||
widget.message.files!.isNotEmpty) ...[
|
||||
if (activeFiles != null && activeFiles.isNotEmpty) ...[
|
||||
_buildFilesFromArray(),
|
||||
const SizedBox(height: Spacing.md),
|
||||
] else if (widget.message.attachmentIds != null &&
|
||||
@@ -729,6 +737,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
messageId: widget.message.id,
|
||||
),
|
||||
],
|
||||
|
||||
// Version switcher moved inline with action buttons below
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -896,11 +906,14 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
}
|
||||
|
||||
Widget _buildFilesFromArray() {
|
||||
if (widget.message.files == null || widget.message.files!.isEmpty) {
|
||||
final filesArray = _activeVersionIndex >= 0
|
||||
? widget.message.versions[_activeVersionIndex].files
|
||||
: widget.message.files;
|
||||
if (filesArray == null || filesArray.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final allFiles = widget.message.files!;
|
||||
final allFiles = filesArray;
|
||||
|
||||
// Separate images and non-image files
|
||||
final imageFiles = allFiles
|
||||
@@ -1077,6 +1090,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
);
|
||||
}
|
||||
|
||||
// Deprecated: old in-content version switcher replaced by inline controls with action buttons.
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final ttsState = ref.watch(textToSpeechControllerProvider);
|
||||
@@ -1139,6 +1154,43 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
label: l10n.copy,
|
||||
onTap: widget.onCopy,
|
||||
),
|
||||
if (widget.message.versions.isNotEmpty && !widget.isStreaming) ...[
|
||||
// Inline version toggle: Prev [1/n] Next
|
||||
ChatActionButton(
|
||||
icon: Icons.chevron_left,
|
||||
label: 'Prev',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (_activeVersionIndex < 0) {
|
||||
_activeVersionIndex = widget.message.versions.length - 1;
|
||||
} else if (_activeVersionIndex > 0) {
|
||||
_activeVersionIndex -= 1;
|
||||
}
|
||||
_reparseSections();
|
||||
});
|
||||
},
|
||||
),
|
||||
ConduitChip(
|
||||
label:
|
||||
'${_activeVersionIndex < 0 ? (widget.message.versions.length + 1) : (_activeVersionIndex + 1)}/${widget.message.versions.length + 1}',
|
||||
isCompact: true,
|
||||
),
|
||||
ChatActionButton(
|
||||
icon: Icons.chevron_right,
|
||||
label: 'Next',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (_activeVersionIndex < 0) return; // already live
|
||||
if (_activeVersionIndex < widget.message.versions.length - 1) {
|
||||
_activeVersionIndex += 1;
|
||||
} else {
|
||||
_activeVersionIndex = -1; // move to live
|
||||
}
|
||||
_reparseSections();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
if (isErrorMessage) ...[
|
||||
_buildActionButton(
|
||||
icon: Platform.isIOS
|
||||
|
||||
Reference in New Issue
Block a user