mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
Lets people share screenshots, screen recordings, photos, URLs and text from any iOS app into Buzz. Today Buzz doesn't appear in the share sheet at all. - **Plain Buzz icon:** opens a new Solo chat with the items in its composer. - **Suggested conversation:** iOS offers channels and DMs the person recently sent top-level messages in. Choosing one opens that conversation with the items in its composer. When the conversation is already open, the share goes to that page's composer instead of opening a second one; if another page covers it, Buzz stays where it is and shows "Go back to that conversation to finish sharing", and the items join the draft when the person goes back. - The share extension has no UI and sends nothing. It copies the items into the app group and opens Buzz, which prefills the normal composer, so drafts, attachments, mentions and failed-send handling all stay the app's (see [the ADR](https://github.com/block/buzz/blob/mobile-share-extension-design/docs/adr/0003-share-hands-off-to-the-conversation.md)). - If the conversation already has a draft, Buzz asks whether to add the shared items to it or replace it. - A share to a conversation that's no longer available (a removed community or channel, or an unreadable share) shows "That conversation isn't available". A share while signed out shows "Sign in to share". Suggestions come from an outgoing `INSendMessageIntent` the app donates after a top-level send or a new Solo chat. The notification extension no longer donates incoming messages, so receiving a message never makes a conversation a suggestion; it still turns the push into a communication notification with the sender's avatar. Both use the same conversation identifier, now the plain community and channel IDs instead of a hash, because the extension needs to hand the identifier back to the app. | Before | After | | --- | --- | | <img alt="Share sheet without Buzz" src="https://github.com/user-attachments/assets/452fac1b-7e87-44c3-a442-099f78d0e07b" width="300"> | <img alt="Share sheet with Buzz" src="https://github.com/user-attachments/assets/54b9775d-29b4-49a5-8ab9-5fda8718a02f" width="300"> | | Buzz icon: new Solo chat | Suggested DM with a draft | After Add | | --- | --- | --- | | <img alt="New Solo chat with the shared photo" src="https://github.com/user-attachments/assets/c5da4d8b-1dd8-4e30-9e92-9dba515b3623" width="250"> | <img alt="Add to your draft prompt" src="https://github.com/user-attachments/assets/2f879f8a-3b9f-48e0-b963-2828e90dc9fe" width="250"> | <img alt="Draft with the shared link and photo added" src="https://github.com/user-attachments/assets/12832006-85c5-4447-98b5-62932552c931" width="250"> | https://github.com/user-attachments/assets/b9d380fa-e802-4ed7-89a8-592a03784c8f ### Regressions - Nothing changes for people who never use the share sheet, except that sending a top-level message or starting a Solo chat now makes one local `INInteraction.donate` call (skipped while the age restriction is on, which still purges every interaction). - Communication notifications look the same. Dropping the incoming donation removes work (and a deletion fence) from the notification extension's time budget. - The share hand-off reuses the existing deep link dispatcher. A share link is a new link type; notification and message links take the same paths as before. ### Validation - Added tests: - Dispatcher hand-off: a share to a suggested channel opens it with the shared text and files; shares to a removed community, an unlisted channel, an unknown manifest version or a missing manifest show the error and delete the pending share; share links accept only the extension's UUID ids. - Composer delivery: with an existing draft, Add appends the shared text on a new line and attaches the file, Replace clears the draft first, and the app group copy is deleted either way. - Notification presenter: specializes the notification from the intent, or falls back to the ordinary content when that throws (replaces the donation and deletion-fence tests). - Full mobile Flutter suite and BuzzPushKit Swift tests pass. - Automated app run on an iPhone 17 Pro simulator (iOS 27, dev relay test community), recorded above: sent a DM, shared a photo from Photos with the Buzz icon from a cold start (new Solo chat with the photo attached), then handed a link and photo to a DM that had a draft and chose Add. Also checked that a share's pending files are deleted after delivery. - Limitation: the iOS simulator never shows share-sheet conversation suggestions (it has no `coreduetd`), so the suggested-DM step in the video is staged by writing the same pending share and link the extension writes. Choosing a real suggestion still needs a check on a device. - Known gaps, all confined to sharing: suggestions have no avatar image yet; a suggestion for a conversation left after signing out shows "isn't available"; choosing Later in the unfinished-chat dialog keeps the share until the next new chat; and a pending share the app never receives (for example when Buzz doesn't open) stays in the app group. ### Before merge - [x] On a real iPhone, choose a suggested conversation in the share sheet and confirm the shared items open in that conversation's composer (the simulator can't show suggestions). Waived before merge: the managed test iPhone shows no share-sheet suggestions for any app, so this will be checked on a personal phone after landing. --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com>
248 lines
8.7 KiB
Dart
248 lines
8.7 KiB
Dart
part of '../compose_bar.dart';
|
|
|
|
Future<void> _sendTextOnlyDraft({
|
|
required BuildContext context,
|
|
required _MarkdownEditingController controller,
|
|
required ObjectRef<Map<String, MentionCandidate>> mentionMap,
|
|
required ObjectRef<int> draftRevision,
|
|
required int submittedDraftRevision,
|
|
required FocusNode focusNode,
|
|
required VoidCallback clearComposer,
|
|
required Future<void> Function() addMentionedNonMembers,
|
|
required _ComposeDraftPayload payload,
|
|
required _OutgoingMentions outgoing,
|
|
required ComposeBarOnSend onSend,
|
|
required ScaffoldMessengerState? messenger,
|
|
}) async {
|
|
TextEditingValue? clearedDraftText;
|
|
Map<String, MentionCandidate>? clearedDraftMentions;
|
|
int? clearedDraftRevision;
|
|
|
|
void restoreClearedDraft() {
|
|
if (!context.mounted ||
|
|
clearedDraftText == null ||
|
|
clearedDraftMentions == null ||
|
|
clearedDraftRevision == null ||
|
|
draftRevision.value != clearedDraftRevision) {
|
|
return;
|
|
}
|
|
mentionMap.value
|
|
..clear()
|
|
..addAll(clearedDraftMentions);
|
|
controller.value = clearedDraftText;
|
|
focusNode.requestFocus();
|
|
}
|
|
|
|
try {
|
|
await addMentionedNonMembers();
|
|
// A draft that changed while adding ran is not the draft the user sent.
|
|
// Keep it and send nothing.
|
|
if (context.mounted && draftRevision.value != submittedDraftRevision) {
|
|
messenger?.showFeedbackSnackBar(
|
|
const SnackBar(content: Text('Not sent. Message changed.')),
|
|
kind: ToastKind.error,
|
|
);
|
|
return;
|
|
}
|
|
// Clear before optimistic insertion so the outgoing row and draft never
|
|
// appear simultaneously during the send transition.
|
|
if (context.mounted) {
|
|
clearedDraftText = controller.value;
|
|
clearedDraftMentions = Map<String, MentionCandidate>.of(mentionMap.value);
|
|
clearComposer();
|
|
clearedDraftRevision = draftRevision.value;
|
|
}
|
|
await onSend(
|
|
payload.content,
|
|
outgoing.pubkeys,
|
|
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
|
|
);
|
|
} on StateError {
|
|
restoreClearedDraft();
|
|
_reportSendCancelledByCommunitySwitch(messenger);
|
|
} catch (error) {
|
|
// The caller runs unawaited, so surface publish failures and restore the
|
|
// sent draft unless the user has already started a new one.
|
|
restoreClearedDraft();
|
|
messenger?.showFeedbackSnackBar(
|
|
SnackBar(content: Text(_composeSendErrorMessage(error))),
|
|
kind: ToastKind.error,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _useComposeDraftLifecycle({
|
|
required BuildContext context,
|
|
required ObjectRef<_ComposerVoiceNote> voiceNote,
|
|
required ObjectRef<Map<String, MentionCandidate>> mentionMap,
|
|
required WidgetRef ref,
|
|
required _MarkdownEditingController controller,
|
|
required String draftKey,
|
|
required String channelId,
|
|
required String? threadHeadId,
|
|
required String draftIdentity,
|
|
required ObjectRef<int> draftRevision,
|
|
required ObjectRef<String?> prefillText,
|
|
required ValueNotifier<List<_PendingAttachment>> attachments,
|
|
required ObjectRef<int> uploadGeneration,
|
|
required ObjectRef<UploadCancellationToken?> activeUploadCancellation,
|
|
required ValueNotifier<int> uploadingCount,
|
|
required ValueNotifier<bool> isSending,
|
|
required ValueNotifier<_AttachmentSurface> attachmentSurface,
|
|
required ValueNotifier<String?> uploadError,
|
|
required _IOSAttachmentPopoverController iosAttachmentPopover,
|
|
required VoidCallback onDraftIdentityChanged,
|
|
}) {
|
|
// Retire the old listener before restoring another scope's text: replacement
|
|
// effects can run before the previous effect's cleanup.
|
|
final owner = useMemoized(Object.new, [draftKey, draftIdentity]);
|
|
final currentOwner = useRef(owner)..value = owner;
|
|
final lastDraftIdentity = useRef<String?>(null);
|
|
useEffect(() {
|
|
final identity = '$draftIdentity\u0000$draftKey';
|
|
final shouldHydrate = lastDraftIdentity.value != identity;
|
|
final identityChanged =
|
|
lastDraftIdentity.value != null && lastDraftIdentity.value != identity;
|
|
lastDraftIdentity.value = identity;
|
|
final saved = ref.read(composeDraftsProvider.notifier).draftFor(draftKey);
|
|
if (shouldHydrate) {
|
|
mentionMap.value
|
|
..clear()
|
|
..addAll({
|
|
for (final entry
|
|
in (saved?.mentionKeys ?? <String, String>{}).entries)
|
|
entry.key: MentionCandidate(
|
|
pubkey: entry.value,
|
|
displayName: entry.key,
|
|
requiresRevalidation: true,
|
|
),
|
|
});
|
|
}
|
|
if (identityChanged) {
|
|
prefillText.value = null;
|
|
draftRevision.value += 1;
|
|
onDraftIdentityChanged();
|
|
uploadGeneration.value += 1;
|
|
activeUploadCancellation.value?.cancel();
|
|
activeUploadCancellation.value = null;
|
|
uploadingCount.value = 0;
|
|
isSending.value = false;
|
|
attachmentSurface.value = _AttachmentSurface.closed;
|
|
uploadError.value = null;
|
|
unawaited(iosAttachmentPopover.dispose());
|
|
final staleAttachments = attachments.value;
|
|
attachments.value = const [];
|
|
unawaited(_deleteOwnedAttachments(staleAttachments));
|
|
controller.text = saved?.text ?? '';
|
|
} else if (saved != null && controller.text.isEmpty) {
|
|
controller.text = saved.text;
|
|
}
|
|
|
|
var lastPersistedText = controller.text;
|
|
var lastBindings = <String, String>{
|
|
for (final e in mentionMap.value.entries) e.key: e.value.pubkey,
|
|
};
|
|
void persistDraft() {
|
|
if (!identical(currentOwner.value, owner)) return;
|
|
final text = controller.text;
|
|
// An untouched agent prefill is not a draft.
|
|
if (text == prefillText.value) return;
|
|
if (text != lastPersistedText) {
|
|
// Prune before the atomic snapshot, not in a later editor listener.
|
|
// Code formatting hides a binding without deleting its literal.
|
|
final retained = mentionOccurrences(
|
|
text.replaceAll('`', ' '),
|
|
mentionMap.value.keys,
|
|
).map((range) => range.label).toSet();
|
|
// Whole-record taint has no literal to prune. Removing every @ is a
|
|
// safe reset; unrelated edits must not turn lost keys into name lookup.
|
|
mentionMap.value.removeWhere(
|
|
(label, _) =>
|
|
label.isEmpty ? !text.contains('@') : !retained.contains(label),
|
|
);
|
|
}
|
|
final bindings = <String, String>{
|
|
for (final e in mentionMap.value.entries) e.key: e.value.pubkey,
|
|
};
|
|
if (text == lastPersistedText && mapEquals(bindings, lastBindings)) {
|
|
return;
|
|
}
|
|
lastBindings = bindings;
|
|
lastPersistedText = text;
|
|
draftRevision.value += 1;
|
|
ref
|
|
.read(composeDraftsProvider.notifier)
|
|
.save(
|
|
key: draftKey,
|
|
channelId: channelId,
|
|
threadHeadId: threadHeadId,
|
|
text: text,
|
|
mentionKeys: bindings,
|
|
);
|
|
}
|
|
|
|
controller.addListener(persistDraft);
|
|
return () => controller.removeListener(persistDraft);
|
|
}, [controller, draftKey, draftIdentity, onDraftIdentityChanged]);
|
|
_useComposerShareDelivery(
|
|
context: context,
|
|
ref: ref,
|
|
draftKey: draftKey,
|
|
controller: controller,
|
|
attachments: attachments,
|
|
draftRevision: draftRevision,
|
|
voiceNote: voiceNote,
|
|
uploadError: uploadError,
|
|
);
|
|
}
|
|
|
|
// Report aggregate work independently of rebuilds, including sub-frame failures.
|
|
void _useComposerBusy({
|
|
required ValueNotifier<bool> isSending,
|
|
required ValueNotifier<int> uploadingCount,
|
|
required ValueNotifier<int> preparingAttachments,
|
|
required bool isDraft,
|
|
required ValueChanged<bool>? onChanged,
|
|
}) {
|
|
final busyCallback = useRef(onChanged);
|
|
busyCallback.value = onChanged;
|
|
useEffect(() {
|
|
var active = true;
|
|
void reportCurrent() {
|
|
if (!active) return;
|
|
busyCallback.value?.call(
|
|
isSending.value ||
|
|
uploadingCount.value > 0 ||
|
|
(isDraft && preparingAttachments.value > 0),
|
|
);
|
|
}
|
|
|
|
void reportBusy() {
|
|
// Scope changes can reset the composer from a build-time effect.
|
|
// Ordinary operation completions still release the parent immediately.
|
|
if (SchedulerBinding.instance.schedulerPhase ==
|
|
SchedulerPhase.persistentCallbacks) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => reportCurrent());
|
|
} else {
|
|
reportCurrent();
|
|
}
|
|
}
|
|
|
|
final sources = [isSending, uploadingCount, preparingAttachments];
|
|
for (final source in sources) {
|
|
source.addListener(reportBusy);
|
|
}
|
|
return () {
|
|
active = false;
|
|
for (final source in sources) {
|
|
source.removeListener(reportBusy);
|
|
}
|
|
// Disposal can happen during a parent build. Release after that frame.
|
|
final callback = busyCallback.value;
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => callback?.call(false),
|
|
);
|
|
};
|
|
}, [isSending, uploadingCount, preparingAttachments, isDraft]);
|
|
}
|