mirror of
https://github.com/BlueBubblesApp/bluebubbles-app.git
synced 2025-08-06 19:44:08 +08:00
Add private API attachment sending
This commit is contained in:
@ -651,6 +651,7 @@ class ChatCreatorState extends OptimizedState<ChatCreator> {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
if (!(createCompleter?.isCompleted ?? true)) return;
|
||||
|
@ -36,7 +36,7 @@ void sendEffectAction(
|
||||
String? chatGuid,
|
||||
Future<void> Function({String? effect}) sendMessage,
|
||||
) {
|
||||
if (!ss.settings.enablePrivateAPI.value || (text.isEmpty && subjectText.isEmpty)) return;
|
||||
if (!ss.settings.enablePrivateAPI.value) return;
|
||||
String typeSelected = "bubble";
|
||||
final bubbleEffects = ["slam", "loud", "gentle", "invisible ink"];
|
||||
final screenEffects = ["echo", "spotlight", "balloons", "confetti", "love", "lasers", "fireworks", "celebration"];
|
||||
@ -283,90 +283,91 @@ void sendEffectAction(
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Theme(
|
||||
data: context.theme.copyWith(
|
||||
// in case some components still use legacy theming
|
||||
primaryColor: context.theme.colorScheme.bubble(context, true),
|
||||
colorScheme: context.theme.colorScheme.copyWith(
|
||||
primary: context.theme.colorScheme.bubble(context, true),
|
||||
onPrimary: context.theme.colorScheme.onBubble(context, true),
|
||||
surface: ss.settings.monetTheming.value == Monet.full
|
||||
? null
|
||||
: (context.theme.extensions[BubbleColors] as BubbleColors?)?.receivedBubbleColor,
|
||||
onSurface: ss.settings.monetTheming.value == Monet.full
|
||||
? null
|
||||
: (context.theme.extensions[BubbleColors] as BubbleColors?)?.onReceivedBubbleColor,
|
||||
if (text.isNotEmpty || subjectText.isNotEmpty)
|
||||
Theme(
|
||||
data: context.theme.copyWith(
|
||||
// in case some components still use legacy theming
|
||||
primaryColor: context.theme.colorScheme.bubble(context, true),
|
||||
colorScheme: context.theme.colorScheme.copyWith(
|
||||
primary: context.theme.colorScheme.bubble(context, true),
|
||||
onPrimary: context.theme.colorScheme.onBubble(context, true),
|
||||
surface: ss.settings.monetTheming.value == Monet.full
|
||||
? null
|
||||
: (context.theme.extensions[BubbleColors] as BubbleColors?)?.receivedBubbleColor,
|
||||
onSurface: ss.settings.monetTheming.value == Monet.full
|
||||
? null
|
||||
: (context.theme.extensions[BubbleColors] as BubbleColors?)?.onReceivedBubbleColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
key: key,
|
||||
padding: const EdgeInsets.only(right: 5.0),
|
||||
child: BubbleEffects(
|
||||
globalKey: key,
|
||||
part: 0,
|
||||
message: message,
|
||||
showTail: true,
|
||||
child: ClipPath(
|
||||
clipper: TailClipper(
|
||||
isFromMe: true,
|
||||
showTail: true,
|
||||
connectLower: false,
|
||||
connectUpper: false,
|
||||
),
|
||||
child: Container(
|
||||
key: key,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: ns.width(context) * MessageWidgetController.maxBubbleSizeFactor - 40,
|
||||
minHeight: 40,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
key: key,
|
||||
padding: const EdgeInsets.only(right: 5.0),
|
||||
child: BubbleEffects(
|
||||
globalKey: key,
|
||||
part: 0,
|
||||
message: message,
|
||||
showTail: true,
|
||||
child: ClipPath(
|
||||
clipper: TailClipper(
|
||||
isFromMe: true,
|
||||
showTail: true,
|
||||
connectLower: false,
|
||||
connectUpper: false,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 15).add(const EdgeInsets.only(right: 10)),
|
||||
color: context.theme.colorScheme.primary,
|
||||
child: CustomAnimationBuilder<Movie>(
|
||||
control: animController,
|
||||
tween: MovieTween()
|
||||
..scene(begin: Duration.zero, duration: const Duration(milliseconds: 1), curve: Curves.easeInOut)
|
||||
.tween("size", 1.0.tweenTo(1.0))
|
||||
..scene(begin: const Duration(milliseconds: 1), duration: const Duration(milliseconds: 500), curve: Curves.easeInOut)
|
||||
.tween("size", 0.0.tweenTo(0.5))
|
||||
..scene(begin: const Duration(milliseconds: 1000), duration: const Duration(milliseconds: 800), curve: Curves.easeInOut)
|
||||
.tween("size", 0.5.tweenTo(1.0)),
|
||||
duration: const Duration(milliseconds: 1800),
|
||||
animationStatusListener: (status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
setState(() {
|
||||
animController = Control.stop;
|
||||
});
|
||||
}
|
||||
},
|
||||
builder: (context, anim, child) {
|
||||
final value1 = anim.get("size");
|
||||
return Transform.scale(
|
||||
scale: value1,
|
||||
alignment: Alignment.center,
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: buildMessageSpans(
|
||||
context,
|
||||
MessagePart(part: 0, text: message.text),
|
||||
message,
|
||||
child: Container(
|
||||
key: key,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: ns.width(context) * MessageWidgetController.maxBubbleSizeFactor - 40,
|
||||
minHeight: 40,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 15).add(const EdgeInsets.only(right: 10)),
|
||||
color: context.theme.colorScheme.primary,
|
||||
child: CustomAnimationBuilder<Movie>(
|
||||
control: animController,
|
||||
tween: MovieTween()
|
||||
..scene(begin: Duration.zero, duration: const Duration(milliseconds: 1), curve: Curves.easeInOut)
|
||||
.tween("size", 1.0.tweenTo(1.0))
|
||||
..scene(begin: const Duration(milliseconds: 1), duration: const Duration(milliseconds: 500), curve: Curves.easeInOut)
|
||||
.tween("size", 0.0.tweenTo(0.5))
|
||||
..scene(begin: const Duration(milliseconds: 1000), duration: const Duration(milliseconds: 800), curve: Curves.easeInOut)
|
||||
.tween("size", 0.5.tweenTo(1.0)),
|
||||
duration: const Duration(milliseconds: 1800),
|
||||
animationStatusListener: (status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
setState(() {
|
||||
animController = Control.stop;
|
||||
});
|
||||
}
|
||||
},
|
||||
builder: (context, anim, child) {
|
||||
final value1 = anim.get("size");
|
||||
return Transform.scale(
|
||||
scale: value1,
|
||||
alignment: Alignment.center,
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: buildMessageSpans(
|
||||
context,
|
||||
MessagePart(part: 0, text: message.text),
|
||||
message,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
child: Text(
|
||||
|
@ -35,7 +35,7 @@ class _SendAnimationState extends CustomState<SendAnimation, Tuple6<List<Platfor
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> send(Tuple6<List<PlatformFile>, String, String, String?, int?, String?> tuple) async {
|
||||
Future<void> send(Tuple6<List<PlatformFile>, String, String, String?, int?, String?> tuple, bool isAudioMessage) async {
|
||||
// do not add anything above this line, the attachments must be extracted first
|
||||
final attachments = List<PlatformFile>.from(tuple.item1);
|
||||
final text = tuple.item2;
|
||||
@ -67,6 +67,9 @@ class _SendAnimationState extends CustomState<SendAnimation, Tuple6<List<Platfor
|
||||
],
|
||||
isFromMe: true,
|
||||
handleId: 0,
|
||||
threadOriginatorGuid: i == 0 ? replyGuid : null,
|
||||
threadOriginatorPart: i == 0 ? "${part ?? 0}:0:0" : null,
|
||||
expressiveSendStyleId: effectId,
|
||||
);
|
||||
message.generateTempGuid();
|
||||
message.attachments.first!.guid = message.guid;
|
||||
@ -74,6 +77,7 @@ class _SendAnimationState extends CustomState<SendAnimation, Tuple6<List<Platfor
|
||||
type: QueueType.sendAttachment,
|
||||
chat: controller.chat,
|
||||
message: message,
|
||||
customArgs: {"audio": isAudioMessage}
|
||||
));
|
||||
}
|
||||
|
||||
@ -81,8 +85,8 @@ class _SendAnimationState extends CustomState<SendAnimation, Tuple6<List<Platfor
|
||||
final _message = Message(
|
||||
text: text.isEmpty && subject.isNotEmpty ? subject : text,
|
||||
subject: text.isEmpty && subject.isNotEmpty ? null : subject,
|
||||
threadOriginatorGuid: replyGuid,
|
||||
threadOriginatorPart: "${part ?? 0}:0:0",
|
||||
threadOriginatorGuid: attachments.isEmpty ? replyGuid : null,
|
||||
threadOriginatorPart: attachments.isEmpty ? "${part ?? 0}:0:0" : null,
|
||||
expressiveSendStyleId: effectId,
|
||||
dateCreated: DateTime.now(),
|
||||
hasAttachments: false,
|
||||
|
@ -263,13 +263,6 @@ class ConversationTextFieldState extends CustomState<ConversationTextField, void
|
||||
showSnackbar("Error", "Something went wrong!");
|
||||
}
|
||||
} else {
|
||||
if (controller.textController.text.isEmpty && controller.subjectTextController.text.isEmpty) {
|
||||
if (controller.replyToMessage != null) {
|
||||
return showSnackbar("Error", "Replies must be sent with a text message!");
|
||||
} else if (effect != null) {
|
||||
return showSnackbar("Error", "Effects must be sent with a text message!");
|
||||
}
|
||||
}
|
||||
await controller.send(
|
||||
controller.pickedAttachments,
|
||||
controller.textController.text,
|
||||
@ -277,6 +270,7 @@ class ConversationTextFieldState extends CustomState<ConversationTextField, void
|
||||
controller.replyToMessage?.item1.threadOriginatorGuid ?? controller.replyToMessage?.item1.guid,
|
||||
controller.replyToMessage?.item2,
|
||||
effect,
|
||||
false,
|
||||
);
|
||||
}
|
||||
controller.pickedAttachments.clear();
|
||||
|
@ -135,7 +135,7 @@ class _TextFieldSuffixState extends OptimizedState<TextFieldSuffix> {
|
||||
onPressed: () async {
|
||||
await widget.controller!.send(
|
||||
[file],
|
||||
"", "", null, null, null,
|
||||
"", "", null, null, null, true,
|
||||
);
|
||||
deleteAudioRecording(file.path!);
|
||||
Get.back();
|
||||
|
@ -351,6 +351,32 @@ class _PrivateAPIPanelState extends CustomState<PrivateAPIPanel, void, PrivateAP
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedSizeAndFade.showHide(
|
||||
show: controller.serverVersionCode.value >= 208,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
color: tileColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 15.0),
|
||||
child: SettingsDivider(color: context.theme.colorScheme.surfaceVariant),
|
||||
),
|
||||
),
|
||||
SettingsSwitch(
|
||||
onChanged: (bool val) {
|
||||
ss.settings.privateAPIAttachmentSend.value = val;
|
||||
saveSettings();
|
||||
},
|
||||
initialVal: ss.settings.privateAPIAttachmentSend.value,
|
||||
title: "Private API Attachment Send",
|
||||
subtitle: "Send attachments using the Private API",
|
||||
backgroundColor: tileColor,
|
||||
isThreeLine: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
@ -78,6 +78,7 @@ class Settings {
|
||||
final RxBool privateManualMarkAsRead = false.obs;
|
||||
final RxBool privateSubjectLine = false.obs;
|
||||
final RxBool privateAPISend = false.obs;
|
||||
final RxBool privateAPIAttachmentSend = false.obs;
|
||||
|
||||
// Redacted Mode Settings
|
||||
final RxBool redactedMode = false.obs;
|
||||
@ -234,6 +235,7 @@ class Settings {
|
||||
'receiveSoundPath': receiveSoundPath.value,
|
||||
'syncContactsAutomatically': syncContactsAutomatically.value,
|
||||
'privateAPISend': privateAPISend.value,
|
||||
'privateAPIAttachmentSend': privateAPIAttachmentSend.value,
|
||||
'highlightSelectedChat': highlightSelectedChat.value,
|
||||
'enablePrivateAPI': enablePrivateAPI.value,
|
||||
'privateSendTypingIndicators': privateSendTypingIndicators.value,
|
||||
@ -340,6 +342,7 @@ class Settings {
|
||||
ss.settings.receiveSoundPath.value = map['receiveSoundPath'];
|
||||
ss.settings.syncContactsAutomatically.value = map['syncContactsAutomatically'];
|
||||
ss.settings.privateAPISend.value = map['privateAPISend'] ?? false;
|
||||
ss.settings.privateAPIAttachmentSend.value = map['privateAPIAttachmentSend'] ?? false;
|
||||
ss.settings.enablePrivateAPI.value = map['enablePrivateAPI'] ?? false;
|
||||
ss.settings.privateSendTypingIndicators.value = map['privateSendTypingIndicators'] ?? false;
|
||||
ss.settings.privateMarkChatAsRead.value = map['privateMarkChatAsRead'] ?? false;
|
||||
@ -450,6 +453,7 @@ class Settings {
|
||||
s.receiveSoundPath.value = map['receiveSoundPath'];
|
||||
s.syncContactsAutomatically.value = map['syncContactsAutomatically'] ?? false;
|
||||
s.privateAPISend.value = map['privateAPISend'] ?? false;
|
||||
s.privateAPIAttachmentSend.value = map['privateAPIAttachmentSend'] ?? false;
|
||||
s.enablePrivateAPI.value = map['enablePrivateAPI'] ?? false;
|
||||
s.privateSendTypingIndicators.value = map['privateSendTypingIndicators'] ?? false;
|
||||
s.privateMarkChatAsRead.value = map['privateMarkChatAsRead'] ?? false;
|
||||
|
@ -142,14 +142,27 @@ class ActionHandler extends GetxService {
|
||||
await c.addMessage(m);
|
||||
}
|
||||
|
||||
Future<void> sendAttachment(Chat c, Message m) async {
|
||||
Future<void> sendAttachment(Chat c, Message m, bool isAudioMessage) async {
|
||||
if (m.attachments.isEmpty || m.attachments.firstOrNull?.bytes == null) return;
|
||||
final attachment = m.attachments.first!;
|
||||
final progress = attachmentProgress.firstWhere((e) => e.item1 == attachment.guid);
|
||||
final completer = Completer<void>();
|
||||
latestCancelToken = CancelToken();
|
||||
http.sendAttachment(c.guid, attachment.guid!, PlatformFile(name: attachment.transferName!, bytes: attachment.bytes, path: attachment.path, size: attachment.totalBytes ?? 0),
|
||||
http.sendAttachment(
|
||||
c.guid,
|
||||
attachment.guid!,
|
||||
PlatformFile(name: attachment.transferName!, bytes: attachment.bytes, path: attachment.path, size: attachment.totalBytes ?? 0),
|
||||
onSendProgress: (count, total) => progress.item2.value = count / attachment.bytes!.length,
|
||||
method: (ss.settings.enablePrivateAPI.value
|
||||
&& ss.settings.privateAPIAttachmentSend.value)
|
||||
|| (m.subject?.isNotEmpty ?? false)
|
||||
|| m.threadOriginatorGuid != null
|
||||
|| m.expressiveSendStyleId != null
|
||||
? "private-api" : "apple-script",
|
||||
selectedMessageGuid: m.threadOriginatorGuid,
|
||||
effectId: m.expressiveSendStyleId,
|
||||
partIndex: int.tryParse(m.threadOriginatorPart?.split(":").firstOrNull ?? ""),
|
||||
isAudioMessage: isAudioMessage,
|
||||
cancelToken: latestCancelToken,
|
||||
).then((response) async {
|
||||
latestCancelToken = null;
|
||||
|
@ -35,7 +35,7 @@ class OutgoingQueue extends Queue {
|
||||
await ah.sendMessage(item.chat, item.message, item.selected, item.reaction);
|
||||
break;
|
||||
case QueueType.sendAttachment:
|
||||
await ah.sendAttachment(item.chat, item.message);
|
||||
await ah.sendAttachment(item.chat, item.message, item.customArgs?['audio'] ?? false);
|
||||
break;
|
||||
default:
|
||||
Logger.info("Unhandled queue event: ${describeEnum(item.type)}");
|
||||
|
@ -518,7 +518,7 @@ class HttpService extends GetxService {
|
||||
/// Send an attachment. [chatGuid] specifies the chat, [tempGuid] specifies a
|
||||
/// temporary guid to avoid duplicate messages being sent, [file] is the
|
||||
/// body of the message.
|
||||
Future<Response> sendAttachment(String chatGuid, String tempGuid, PlatformFile file, {void Function(int, int)? onSendProgress, CancelToken? cancelToken}) async {
|
||||
Future<Response> sendAttachment(String chatGuid, String tempGuid, PlatformFile file, {void Function(int, int)? onSendProgress, String? method, String? effectId, String? subject, String? selectedMessageGuid, int? partIndex, bool? isAudioMessage, CancelToken? cancelToken}) async {
|
||||
return runApiGuarded(() async {
|
||||
final fileName = file.path!.split('/').last;
|
||||
final formData = FormData.fromMap({
|
||||
@ -526,6 +526,11 @@ class HttpService extends GetxService {
|
||||
"chatGuid": chatGuid,
|
||||
"tempGuid": tempGuid,
|
||||
"name": fileName,
|
||||
"method": method,
|
||||
"effectId": effectId,
|
||||
"subject": subject,
|
||||
"selectedMessageGuid": selectedMessageGuid,
|
||||
"partIndex": partIndex,
|
||||
});
|
||||
final response = await dio.post(
|
||||
"$origin/message/attachment",
|
||||
|
@ -75,7 +75,7 @@ class ConversationViewController extends StatefulController with SingleGetTicker
|
||||
bool keyboardOpen = false;
|
||||
double _keyboardOffset = 0;
|
||||
Timer? _scrollDownDebounce;
|
||||
Future<void> Function(Tuple6<List<PlatformFile>, String, String, String?, int?, String?>)? sendFunc;
|
||||
Future<void> Function(Tuple6<List<PlatformFile>, String, String, String?, int?, String?>, bool)? sendFunc;
|
||||
bool isProcessingImage = false;
|
||||
|
||||
@override
|
||||
@ -146,8 +146,8 @@ class ConversationViewController extends StatefulController with SingleGetTicker
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> send(List<PlatformFile> attachments, String text, String subject, String? replyGuid, int? replyPart, String? effectId) async {
|
||||
sendFunc?.call(Tuple6(attachments, text, subject, replyGuid, replyPart, effectId));
|
||||
Future<void> send(List<PlatformFile> attachments, String text, String subject, String? replyGuid, int? replyPart, String? effectId, bool isAudioMessage) async {
|
||||
sendFunc?.call(Tuple6(attachments, text, subject, replyGuid, replyPart, effectId), isAudioMessage);
|
||||
}
|
||||
|
||||
void queueImage(Tuple4<Attachment, PlatformFile, BuildContext, Completer<Uint8List>> item) {
|
||||
|
Reference in New Issue
Block a user