mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(mobile): restore Solo chats from channel labels (#8466)
Restore Solo chats and project placement on mobile using `buzz.solo`
labels and creator metadata. Invalidate stale channel details when
identity changes so opening and sending preserve refreshed Solo
identity; shared chats retain ordinary sending. New mobile Solo chats
publish the discovery label, and the personal send guard enforces
ownership even when route hints or legacy placement suggest Solo.
The current desktop producer lives in `block/buzz-app`. [Contract
documentation](0201ca0572/docs/solo-mobile-contract.md)
links its production creation call to relay identity generation and
mobile regression coverage. Public metadata fields are documented.
Validation: clean analysis; 3,542 mobile tests (5 skipped), 4 push
checks, and 280 core unit/doc tests passed. Removing the cache fix
reproduces the send failure. Local push hooks were skipped with owner
approval after an unrelated terminal timeout and disk exhaustion; full
local CI is not green. GitHub CI must validate the pushed commit. The
initial build was installed/launched on iPhone; follow-up native and
live cross-client confirmation remain pending.
The ownership regression supplies a valid owned member recipient and
checks the exact rejection. A New chat UI test captures the real signed
kind-9007 command. Both regressions were mutation-verified: removing the
ownership guard or the production creation label causes the
corresponding test to fail.
---------
Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -368,6 +368,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_solo_creation_preserves_mobile_identity_contract() {
|
||||
let fixture: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../test-fixtures/solo-creation.json"))
|
||||
.expect("Solo creation fixture");
|
||||
let tags: Vec<Vec<String>> = serde_json::from_value(fixture["creation"]["tags"].clone())
|
||||
.expect("captured production command tags");
|
||||
let labels = super::parse_channel_labels(
|
||||
tags.iter()
|
||||
.filter(|tag| tag[0] == "t")
|
||||
.map(|tag| tag[1].as_str()),
|
||||
)
|
||||
.expect("valid creation labels");
|
||||
let channel_type = tags
|
||||
.iter()
|
||||
.find(|tag| tag[0] == "channel_type")
|
||||
.expect("creation type")[1]
|
||||
.as_str();
|
||||
let creator =
|
||||
hex::decode(fixture["creator"].as_str().expect("creator")).expect("creator hex");
|
||||
let actual: Vec<Vec<String>> = group_state_identity_tags(channel_type, &labels, &creator)
|
||||
.expect("relay identity tags")
|
||||
.into_iter()
|
||||
.map(|tag| tag.to_vec())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
serde_json::to_value(actual).expect("identity JSON"),
|
||||
fixture["identity_tags"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_state_identity_tags_put_the_type_first_then_labels_then_the_creator() {
|
||||
let creator = [0xab; 32];
|
||||
|
||||
42
docs/solo-mobile-contract.md
Normal file
42
docs/solo-mobile-contract.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Solo desktop/mobile contract
|
||||
|
||||
The current Solo producer is **[block/buzz-app](https://github.com/block/buzz-app)**,
|
||||
not the older `desktop/` client retained in this repository.
|
||||
|
||||
The captured producer revision is
|
||||
[`64726576ed3b082daa5786e5e7ca6b5a018663a1`](https://github.com/block/buzz-app/commit/64726576ed3b082daa5786e5e7ca6b5a018663a1):
|
||||
|
||||
- [`SoloDraft.tsx`](https://github.com/block/buzz-app/blob/64726576ed3b082daa5786e5e7ca6b5a018663a1/src/features/solo/SoloDraft.tsx) calls `session.channelWrites.create`.
|
||||
- [`channel-writes.ts`](https://github.com/block/buzz-app/blob/64726576ed3b082daa5786e5e7ca6b5a018663a1/src/features/relay/channel-writes.ts) emits kind 9007 with `channel_type=stream`, `visibility=private`, and `t=buzz.solo`. Its existing production-service test is “creates a Solo chat in its single durable outbox command” in `channel-writes.test.ts`.
|
||||
- [`model.ts`](https://github.com/block/buzz-app/blob/64726576ed3b082daa5786e5e7ca6b5a018663a1/src/features/channel-templates/model.ts) retains project assignments but drops the legacy `groups.channels` discovery list.
|
||||
|
||||
The desktop does **not** author `P`. The relay derives the creator from the
|
||||
signed creation event. In this repository, `channel_labels_from_tags` in
|
||||
`crates/buzz-relay/src/handlers/side_effects.rs` parses creation labels;
|
||||
`group_state_identity_tags` in `crates/buzz-core/src/channel.rs` emits the
|
||||
leading type `t`, subsequent label `t` tags, and uppercase creator `P`.
|
||||
`emit_group_discovery_events` uses that builder for relay metadata. Mobile reads those
|
||||
facts through `ChannelData.fromEvent` and its channel providers.
|
||||
|
||||
## Reproducible contract coverage
|
||||
|
||||
`test-fixtures/solo-creation.json` contains an **actual captured production
|
||||
create command**, plus expected relay identity tags using a synthetic creator.
|
||||
To recapture it from a checkout of the pinned desktop revision:
|
||||
|
||||
```sh
|
||||
node --experimental-transform-types scripts/contracts/capture-solo-creation.mjs /path/to/buzz-app > test-fixtures/solo-creation.json
|
||||
```
|
||||
|
||||
The script invokes `createChannelWrites(...).create(...)` and captures its
|
||||
outbox command without publishing anything. The core test
|
||||
`desktop_solo_creation_preserves_mobile_identity_contract` feeds those command
|
||||
labels through the production parser and relay identity builder. The mobile
|
||||
regression “refresh replaces stale Solo details before opening and sending”
|
||||
consumes the same identity tags through the real list/detail providers, primes
|
||||
old details, refreshes, opens Solo, and sends through the personal sender with
|
||||
an empty legacy chat list. Existing widget coverage checks project and unfiled
|
||||
placement and shared-chat ordinary sending.
|
||||
|
||||
These are compositional contract tests, not a live desktop/relay/device E2E
|
||||
run. Physical-device installation and launch alone do not prove that journey.
|
||||
@@ -11,6 +11,9 @@ const privateChannelAddDeniedMessage =
|
||||
class Channel {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// NIP-CL labels from metadata; presentation hints, never access grants.
|
||||
final List<String> labels;
|
||||
final String channelType; // "stream", "forum", "dm"
|
||||
final String visibility; // "open", "private"
|
||||
final String description;
|
||||
@@ -31,6 +34,7 @@ class Channel {
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.channelType,
|
||||
this.labels = const [],
|
||||
required this.visibility,
|
||||
required this.description,
|
||||
required this.createdBy,
|
||||
@@ -51,6 +55,7 @@ class Channel {
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
channelType: json['channel_type'] as String,
|
||||
labels: (json['labels'] as List<dynamic>? ?? const []).cast<String>(),
|
||||
visibility: json['visibility'] as String,
|
||||
description: (json['description'] as String?) ?? '',
|
||||
topic: json['topic'] as String?,
|
||||
@@ -78,6 +83,14 @@ class Channel {
|
||||
|
||||
bool get isEphemeral => ttlSeconds != null || ttlDeadline != null;
|
||||
|
||||
/// Solo presentation is carried by NIP-CL metadata, not project filing.
|
||||
bool get isSolo => isStream && isPrivate && labels.contains('buzz.solo');
|
||||
|
||||
/// Keep old mobile-created chats visible while older clients migrate.
|
||||
bool isPersonalChat(String? viewer, Iterable<String> legacyChannels) => isSolo
|
||||
? viewer != null && createdBy == viewer
|
||||
: legacyChannels.contains(id);
|
||||
|
||||
bool get isStream => channelType == 'stream';
|
||||
bool get isForum => channelType == 'forum';
|
||||
|
||||
@@ -131,6 +144,7 @@ class Channel {
|
||||
id: id,
|
||||
name: details.name,
|
||||
channelType: details.channelType,
|
||||
labels: details.labels,
|
||||
visibility: details.visibility,
|
||||
description: details.description,
|
||||
topic: details.topic,
|
||||
@@ -158,6 +172,7 @@ class Channel {
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
channelType: channelType,
|
||||
labels: labels,
|
||||
visibility: visibility,
|
||||
description: description ?? this.description,
|
||||
topic: topic,
|
||||
@@ -183,6 +198,9 @@ class Channel {
|
||||
class ChannelDetails {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// NIP-CL labels from metadata; presentation hints, never access grants.
|
||||
final List<String> labels;
|
||||
final String channelType;
|
||||
final String visibility;
|
||||
final String description;
|
||||
@@ -199,6 +217,7 @@ class ChannelDetails {
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.channelType,
|
||||
this.labels = const [],
|
||||
required this.visibility,
|
||||
required this.description,
|
||||
required this.createdBy,
|
||||
@@ -215,6 +234,7 @@ class ChannelDetails {
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
channelType: json['channel_type'] as String,
|
||||
labels: (json['labels'] as List<dynamic>? ?? const []).cast<String>(),
|
||||
visibility: json['visibility'] as String,
|
||||
description: (json['description'] as String?) ?? '',
|
||||
topic: json['topic'] as String?,
|
||||
@@ -235,6 +255,7 @@ class ChannelDetails {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
channelType: channel.channelType,
|
||||
labels: channel.labels,
|
||||
visibility: channel.visibility,
|
||||
description: channel.description,
|
||||
topic: channel.topic,
|
||||
|
||||
@@ -936,9 +936,11 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
|
||||
}
|
||||
var recipients = mentionPubkeys;
|
||||
if (personalChat) {
|
||||
if (mePlacement?.asData?.value.channels
|
||||
.contains(channel.id) !=
|
||||
true) {
|
||||
if (!resolvedChannel.isPersonalChat(
|
||||
ref.read(myPubkeyProvider),
|
||||
mePlacement?.asData?.value.channels ??
|
||||
const [],
|
||||
)) {
|
||||
throw StateError(
|
||||
'Refresh Solo before sending to this chat.',
|
||||
);
|
||||
|
||||
@@ -29,7 +29,10 @@ class ChannelDetailPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final groups = ref.watch(meGroupsProvider).value;
|
||||
final details = ref.watch(channelDetailsProvider(channel.id)).value;
|
||||
final current = details == null ? channel : channel.mergeDetails(details);
|
||||
final placed =
|
||||
current.isPersonalChat(ref.watch(myPubkeyProvider), const []) ||
|
||||
personalChat ||
|
||||
placement == ChannelRoutePlacement.solo ||
|
||||
(groups?.channels.contains(channel.id) ?? false);
|
||||
@@ -45,7 +48,7 @@ class ChannelDetailPage extends ConsumerWidget {
|
||||
}
|
||||
return _ResolvedChannelDetailPage(
|
||||
key: ValueKey((channel.id, placed)),
|
||||
channel: channel,
|
||||
channel: current,
|
||||
personalChat: placed,
|
||||
placementKnown:
|
||||
placed ||
|
||||
|
||||
@@ -38,6 +38,7 @@ class ChannelActions {
|
||||
required String name,
|
||||
required String channelType,
|
||||
required String visibility,
|
||||
List<String> labels = const [],
|
||||
String? description,
|
||||
int? ttlSeconds,
|
||||
}) async {
|
||||
@@ -47,6 +48,7 @@ class ChannelActions {
|
||||
name: name,
|
||||
channelType: channelType,
|
||||
visibility: visibility,
|
||||
labels: labels,
|
||||
description: description,
|
||||
ttlSeconds: ttlSeconds,
|
||||
);
|
||||
|
||||
@@ -490,10 +490,11 @@ ChannelDetails channelDetailsFromEvent(NostrEvent event) {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
channelType: data.channelType,
|
||||
labels: data.labels,
|
||||
visibility: data.visibility,
|
||||
description: data.description,
|
||||
topic: data.topic,
|
||||
createdBy: event.pubkey,
|
||||
createdBy: data.creator ?? event.pubkey,
|
||||
createdAt: eventTime,
|
||||
memberCount: 0,
|
||||
// Same archival-timestamp convention as `_channelFromMeta` — the event's
|
||||
@@ -668,6 +669,7 @@ List<List<String>> buildCreateChannelTags({
|
||||
required String name,
|
||||
required String channelType,
|
||||
required String visibility,
|
||||
List<String> labels = const [],
|
||||
String? description,
|
||||
int? ttlSeconds,
|
||||
}) {
|
||||
@@ -680,6 +682,7 @@ List<List<String>> buildCreateChannelTags({
|
||||
['name', name],
|
||||
['visibility', visibility],
|
||||
['channel_type', channelType],
|
||||
for (final label in labels) ['t', label],
|
||||
if (description case final about? when about.trim().isNotEmpty)
|
||||
['about', about.trim()],
|
||||
if (ttlSeconds != null) ['ttl', ttlSeconds.toString()],
|
||||
|
||||
@@ -232,10 +232,11 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
if (channelsAsync.asData?.value case final data?) {
|
||||
cachedChannels.value = data;
|
||||
}
|
||||
final soloViewer = ref.watch(myPubkeyProvider);
|
||||
final personalIds =
|
||||
ref.watch(meGroupsProvider).asData?.value.channels ?? const <String>[];
|
||||
final channels = cachedChannels.value
|
||||
?.where((channel) => !personalIds.contains(channel.id))
|
||||
?.where((channel) => !channel.isPersonalChat(soloViewer, personalIds))
|
||||
.toList();
|
||||
Future<void> openChannel(Channel channel) async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
@@ -430,16 +431,8 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
return left.name.toLowerCase().compareTo(right.name.toLowerCase());
|
||||
});
|
||||
|
||||
// Invalidate `channelDetailsProvider` entries whose archived state flipped
|
||||
// since the last fetch. Required because `channelDetailsProvider` is a
|
||||
// separate Riverpod cache and `Channel.mergeDetails(details)` overwrites
|
||||
// archivedAt from the cached details — so an active-then-archived channel
|
||||
// (e.g. TTL auto-archive by the relay reaper) could keep showing compose
|
||||
// and manage actions in the detail view until the cache expired naturally.
|
||||
//
|
||||
// Scoped narrowly to the archived flip — broader metadata staleness
|
||||
// (renames, topic changes, etc.) is a separate, pre-existing concern that
|
||||
// already affects this provider for other reasons.
|
||||
// Details are a separate cache. Invalidate changes that would otherwise
|
||||
// overwrite fresh Solo identity or archive state when opening a channel.
|
||||
// Re-check before the first write that other providers can observe. Every
|
||||
// await above is fenced, but the switch can also land in the synchronous
|
||||
// gap, so the guard sits immediately before the write rather than only
|
||||
@@ -451,7 +444,12 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
};
|
||||
for (final channel in channels) {
|
||||
final prev = prevById[channel.id];
|
||||
if (prev != null && prev.isArchived != channel.isArchived) {
|
||||
if (prev == null ||
|
||||
prev.isArchived != channel.isArchived ||
|
||||
prev.channelType != channel.channelType ||
|
||||
prev.visibility != channel.visibility ||
|
||||
prev.createdBy != channel.createdBy ||
|
||||
!listEquals(prev.labels, channel.labels)) {
|
||||
ref.invalidate(channelDetailsProvider(channel.id));
|
||||
}
|
||||
}
|
||||
@@ -623,10 +621,11 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
channelType: data.channelType,
|
||||
labels: data.labels,
|
||||
visibility: data.visibility,
|
||||
description: data.description,
|
||||
topic: data.topic,
|
||||
createdBy: event.pubkey,
|
||||
createdBy: data.creator ?? event.pubkey,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
event.createdAt * 1000,
|
||||
isUtc: true,
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../../../shared/me/me_groups.dart';
|
||||
import '../../../shared/me/me_chat_creation.dart';
|
||||
import '../../../shared/me/me_project_creation.dart';
|
||||
import '../../../shared/me/me_sync.dart';
|
||||
import '../../../shared/relay/relay.dart';
|
||||
import '../../../shared/me/me_recovery.dart';
|
||||
import '../../../shared/me/me_assistants.dart';
|
||||
import '../../../shared/theme/theme.dart';
|
||||
@@ -68,6 +69,7 @@ class MePage extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [assistants]);
|
||||
final groups = ref.watch(meGroupsProvider);
|
||||
final viewer = ref.watch(myPubkeyProvider);
|
||||
final offlineSaveFailed =
|
||||
ref.watch(meOfflineSaveWarningProvider).value ?? false;
|
||||
final syncClosed = ref.watch(meSyncWarningProvider).value ?? false;
|
||||
@@ -196,7 +198,7 @@ class MePage extends HookConsumerWidget {
|
||||
(c) =>
|
||||
c.isMember &&
|
||||
!c.isArchived &&
|
||||
organization.channels.contains(c.id),
|
||||
c.isPersonalChat(viewer, organization.channels),
|
||||
)
|
||||
.toList()
|
||||
..sort(
|
||||
@@ -229,8 +231,7 @@ class MePage extends HookConsumerWidget {
|
||||
Grid.gutter - Grid.xxs,
|
||||
topSectionHeight + Grid.xxs,
|
||||
Grid.gutter - Grid.xxs,
|
||||
organization.channels.isEmpty &&
|
||||
organization.groups.isEmpty
|
||||
visible.isEmpty && organization.groups.isEmpty
|
||||
? 0
|
||||
: MediaQuery.viewPaddingOf(context).bottom +
|
||||
Grid.gutter,
|
||||
@@ -297,7 +298,7 @@ class MePage extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (organization.channels.isNotEmpty ||
|
||||
if (visible.isNotEmpty ||
|
||||
organization.groups.isNotEmpty)
|
||||
MeSection(
|
||||
key: const ValueKey('me-ungrouped-chats'),
|
||||
@@ -324,8 +325,7 @@ class MePage extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (organization.channels.isEmpty &&
|
||||
organization.groups.isEmpty)
|
||||
if (visible.isEmpty && organization.groups.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
|
||||
@@ -28,7 +28,7 @@ class MeUnreadChannelIds extends Notifier<Set<String>> {
|
||||
(c) =>
|
||||
c.isMember &&
|
||||
!c.isArchived &&
|
||||
organization.channels.contains(c.id),
|
||||
c.isPersonalChat(reads.pubkey, organization.channels),
|
||||
)
|
||||
.toList();
|
||||
final pending = active.where((c) => !seeded.contains(c.id)).toList();
|
||||
|
||||
@@ -155,6 +155,7 @@ Future<Channel> _createMeChat(
|
||||
name: name,
|
||||
channelType: 'stream',
|
||||
visibility: 'private',
|
||||
labels: const ['buzz.solo'],
|
||||
),
|
||||
hasAgent: () async {
|
||||
ref.invalidate(channelMembersProvider(channelId));
|
||||
|
||||
@@ -276,6 +276,12 @@ class ChannelData {
|
||||
final String id;
|
||||
final String name;
|
||||
final String channelType;
|
||||
|
||||
/// NIP-CL labels in `t` tags after the leading channel-type tag.
|
||||
final List<String> labels;
|
||||
|
||||
/// Original channel creator from the relay-authored uppercase `P` tag.
|
||||
final String? creator;
|
||||
final String visibility;
|
||||
final String description;
|
||||
final String? topic;
|
||||
@@ -291,6 +297,8 @@ class ChannelData {
|
||||
required this.visibility,
|
||||
required this.description,
|
||||
this.topic,
|
||||
this.labels = const [],
|
||||
this.creator,
|
||||
this.participantPubkeys = const [],
|
||||
this.ttlSeconds,
|
||||
this.ttlDeadline,
|
||||
@@ -336,6 +344,12 @@ class ChannelData {
|
||||
id: id,
|
||||
name: name,
|
||||
channelType: channelType,
|
||||
labels: event.tags
|
||||
.where((t) => t.length >= 2 && t[0] == 't')
|
||||
.skip(1)
|
||||
.map((t) => t[1])
|
||||
.toList(),
|
||||
creator: event.getTagValue('P'),
|
||||
visibility: visibility,
|
||||
description: description,
|
||||
topic: topic,
|
||||
|
||||
@@ -234,6 +234,7 @@ NostrEvent _edit({
|
||||
);
|
||||
|
||||
Widget _buildTestable({
|
||||
bool useRealChannelProviders = false,
|
||||
ProfileNotifier? profileNotifier,
|
||||
String? signingPubkey,
|
||||
bool darkTheme = false,
|
||||
@@ -355,14 +356,16 @@ Widget _buildTestable({
|
||||
myPubkeyProvider.overrideWithValue(signingPubkey),
|
||||
if (presenceCache != null)
|
||||
presenceCacheProvider.overrideWith(() => presenceCache),
|
||||
channelsProvider.overrideWith(() => fakeChannelsNotifier),
|
||||
if (!useRealChannelProviders)
|
||||
channelsProvider.overrideWith(() => fakeChannelsNotifier),
|
||||
channelStarsProvider.overrideWith(_FakeChannelStarsNotifier.new),
|
||||
channelMutesProvider.overrideWith(_FakeChannelMutesNotifier.new),
|
||||
channelDetailsProvider(_channelId).overrideWith(
|
||||
(ref) async =>
|
||||
channelDetails?.call() ??
|
||||
ChannelDetails.fromChannel(resolvedChannel),
|
||||
),
|
||||
if (!useRealChannelProviders)
|
||||
channelDetailsProvider(_channelId).overrideWith(
|
||||
(ref) async =>
|
||||
channelDetails?.call() ??
|
||||
ChannelDetails.fromChannel(resolvedChannel),
|
||||
),
|
||||
channelCanvasProvider(_channelId).overrideWith(
|
||||
(ref) async => ChannelCanvas(
|
||||
content: canvasContent,
|
||||
@@ -561,6 +564,95 @@ double? effectiveFontSizeForText(
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'refresh replaces stale Solo details before opening and sending',
|
||||
(tester) async {
|
||||
final session = _SoloMetadataRelaySession();
|
||||
final solo = _RecordingOrdinarySender();
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [],
|
||||
personalChat: true,
|
||||
signingPubkey: session.creator,
|
||||
useRealChannelProviders: true,
|
||||
disableRetries: true,
|
||||
relaySessionNotifier: session,
|
||||
organization: Stream.value(const MeGroups()),
|
||||
soloSender: solo,
|
||||
members: [
|
||||
ChannelMember(
|
||||
pubkey: 'agent',
|
||||
role: 'bot',
|
||||
joinedAt: DateTime(2026),
|
||||
),
|
||||
],
|
||||
home: Consumer(
|
||||
builder: (context, ref, _) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () async {
|
||||
await ref.read(channelsProvider.notifier).refresh();
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ChannelDetailPage(
|
||||
channel: ref.read(channelsProvider).requireValue.single,
|
||||
personalChat: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Open refreshed Solo'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.text('Open refreshed Solo')),
|
||||
);
|
||||
final initial = container.read(channelsProvider.future);
|
||||
await tester.pumpAndSettle();
|
||||
await initial;
|
||||
final details = container.read(channelDetailsProvider(_channelId).future);
|
||||
await tester.pumpAndSettle();
|
||||
final stale = await details;
|
||||
expect(stale.labels, isEmpty);
|
||||
session.solo = true;
|
||||
await tester.tap(find.text('Open refreshed Solo'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.widget<ComposeBar>(find.byType(ComposeBar)).onSend(
|
||||
'After refresh',
|
||||
['agent'],
|
||||
);
|
||||
expect(solo.sent, ['After refresh']);
|
||||
expect(
|
||||
(await container.read(
|
||||
channelDetailsProvider(_channelId).future,
|
||||
)).labels,
|
||||
['buzz.solo'],
|
||||
);
|
||||
session.solo = false;
|
||||
final refresh = container.read(channelsProvider.notifier).refresh();
|
||||
await tester.pumpAndSettle();
|
||||
await refresh;
|
||||
expect(
|
||||
(await container.read(
|
||||
channelDetailsProvider(_channelId).future,
|
||||
)).labels,
|
||||
isEmpty,
|
||||
);
|
||||
await expectLater(
|
||||
tester.widget<ComposeBar>(find.byType(ComposeBar)).onSend(
|
||||
'After conversion',
|
||||
['agent'],
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(solo.sent, ['After refresh']);
|
||||
},
|
||||
);
|
||||
|
||||
_loadingReviewTests();
|
||||
threadReplyRefreshTests();
|
||||
threadTitleCapsuleTests();
|
||||
@@ -10719,6 +10811,106 @@ void main() {
|
||||
expect(sender.sent, isEmpty);
|
||||
});
|
||||
|
||||
for (final own in [true, false]) {
|
||||
testWidgets('label-only Solo uses owner-aware sending: $own', (
|
||||
tester,
|
||||
) async {
|
||||
final ordinary = _RecordingOrdinarySender(),
|
||||
solo = _RecordingOrdinarySender();
|
||||
final channel = Channel(
|
||||
id: _channelId,
|
||||
name: 'Labeled Solo',
|
||||
channelType: 'stream',
|
||||
visibility: 'private',
|
||||
labels: const ['buzz.solo'],
|
||||
description: '',
|
||||
createdBy: own ? 'self' : 'other',
|
||||
createdAt: DateTime(2026),
|
||||
memberCount: 2,
|
||||
isMember: true,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [],
|
||||
channel: channel,
|
||||
signingPubkey: 'self',
|
||||
personalChat: own,
|
||||
organization: Stream.value(const MeGroups()),
|
||||
sender: ordinary,
|
||||
soloSender: solo,
|
||||
members: [
|
||||
ChannelMember(
|
||||
pubkey: 'agent',
|
||||
role: 'bot',
|
||||
joinedAt: DateTime(2026),
|
||||
),
|
||||
],
|
||||
home: ChannelDetailPage(channel: channel),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester
|
||||
.widget<ComposeBar>(find.byType(ComposeBar))
|
||||
.onSend('Label message', own ? ['agent'] : []);
|
||||
expect(own ? solo.sent : ordinary.sent, ['Label message']);
|
||||
expect(own ? ordinary.sent : solo.sent, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'foreign Solo cannot force the personal sender through a route hint',
|
||||
(tester) async {
|
||||
final ordinary = _RecordingOrdinarySender(),
|
||||
solo = _RecordingOrdinarySender();
|
||||
final channel = Channel(
|
||||
id: _channelId,
|
||||
name: 'Shared Solo',
|
||||
channelType: 'stream',
|
||||
visibility: 'private',
|
||||
labels: const ['buzz.solo'],
|
||||
description: '',
|
||||
createdBy: 'other',
|
||||
createdAt: DateTime(2026),
|
||||
memberCount: 2,
|
||||
isMember: true,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [],
|
||||
channel: channel,
|
||||
signingPubkey: 'self',
|
||||
personalChat: true,
|
||||
organization: Stream.value(const MeGroups(channels: [_channelId])),
|
||||
sender: ordinary,
|
||||
soloSender: solo,
|
||||
members: [
|
||||
ChannelMember(
|
||||
pubkey: 'agent',
|
||||
role: 'bot',
|
||||
joinedAt: DateTime(2026),
|
||||
),
|
||||
],
|
||||
home: ChannelDetailPage(channel: channel, personalChat: true),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await expectLater(
|
||||
tester.widget<ComposeBar>(find.byType(ComposeBar)).onSend('Rejected', [
|
||||
'agent',
|
||||
]),
|
||||
throwsA(
|
||||
isA<StateError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
'Refresh Solo before sending to this chat.',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(solo.sent, isEmpty);
|
||||
expect(ordinary.sent, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('identified Solo sends through the Solo sender', (tester) async {
|
||||
final ordinary = _RecordingOrdinarySender(),
|
||||
solo = _RecordingOrdinarySender();
|
||||
@@ -17882,3 +18074,76 @@ class _RecordingOrdinarySender implements SendMessage {
|
||||
sent.add(content);
|
||||
}
|
||||
}
|
||||
|
||||
class _SoloMetadataRelaySession extends RelaySessionNotifier {
|
||||
final fixture =
|
||||
jsonDecode(File('../test-fixtures/solo-creation.json').readAsStringSync())
|
||||
as Map<String, dynamic>;
|
||||
String get creator => fixture['creator'] as String;
|
||||
@override
|
||||
Future<List<NostrEvent>> queryRelay(
|
||||
List<NostrFilter> filters, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => [
|
||||
for (final filter in filters)
|
||||
...await fetchHistory(filter, timeout: timeout),
|
||||
];
|
||||
bool solo = false;
|
||||
@override
|
||||
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
if (filter.kinds.contains(39002)) {
|
||||
return [
|
||||
NostrEvent(
|
||||
id: 'membership',
|
||||
pubkey: 'relay',
|
||||
createdAt: 1,
|
||||
kind: 39002,
|
||||
tags: [
|
||||
['d', _channelId],
|
||||
['p', creator],
|
||||
],
|
||||
content: '',
|
||||
sig: '',
|
||||
),
|
||||
];
|
||||
}
|
||||
if (filter.kinds.contains(39000)) {
|
||||
return [
|
||||
NostrEvent(
|
||||
id: solo ? 'new' : 'old',
|
||||
pubkey: 'relay',
|
||||
createdAt: solo ? 2 : 1,
|
||||
kind: 39000,
|
||||
tags: [
|
||||
['d', _channelId],
|
||||
['name', 'Solo'],
|
||||
['private'],
|
||||
if (solo) ...[
|
||||
for (final tag in fixture['identity_tags'] as List)
|
||||
List<String>.from(tag as List),
|
||||
] else ...[
|
||||
['t', 'stream'],
|
||||
['P', creator],
|
||||
],
|
||||
],
|
||||
content: '',
|
||||
sig: '',
|
||||
),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function()? onEose,
|
||||
void Function(String)? onClosed,
|
||||
}) async => () {};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
@@ -13,6 +14,52 @@ import 'package:buzz/shared/relay/relay.dart';
|
||||
/// also exposed on `ChannelDetails` MUST be propagated here — otherwise
|
||||
/// `mergeDetails` silently clears that state on the merged Channel.
|
||||
void main() {
|
||||
test(
|
||||
'Solo metadata survives details merges and classifies only own private streams',
|
||||
() {
|
||||
for (final type in ['stream', 'forum', 'dm']) {
|
||||
for (final private in [true, false]) {
|
||||
final details = channelDetailsFromEvent(
|
||||
NostrEvent(
|
||||
id: 'metadata',
|
||||
pubkey: 'relay',
|
||||
createdAt: 1,
|
||||
kind: 39000,
|
||||
tags: [
|
||||
['d', 'chat'],
|
||||
['t', type],
|
||||
['t', 'buzz.solo'],
|
||||
['P', 'owner'],
|
||||
[private ? 'private' : 'public'],
|
||||
],
|
||||
content: '',
|
||||
sig: '',
|
||||
),
|
||||
);
|
||||
final channel = Channel(
|
||||
id: 'chat',
|
||||
name: '',
|
||||
channelType: 'stream',
|
||||
visibility: 'private',
|
||||
description: '',
|
||||
createdBy: 'relay',
|
||||
createdAt: DateTime(2026),
|
||||
memberCount: 1,
|
||||
).mergeDetails(details).copyWith(isMember: true);
|
||||
expect(channel.labels, ['buzz.solo']);
|
||||
expect(channel.createdBy, 'owner');
|
||||
expect(channel.isSolo, type == 'stream' && private);
|
||||
expect(
|
||||
channel.isPersonalChat('owner', const []),
|
||||
type == 'stream' && private,
|
||||
);
|
||||
expect(channel.isPersonalChat('other', const []), isFalse);
|
||||
expect(ChannelDetails.fromChannel(channel).labels, ['buzz.solo']);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('extracts unique relay members from current and legacy tags', () {
|
||||
final pubkeys = relayMemberPubkeysFromEvents([
|
||||
NostrEvent(
|
||||
@@ -220,6 +267,28 @@ void main() {
|
||||
});
|
||||
|
||||
group('buildCreateChannelTags', () {
|
||||
test(
|
||||
'Solo creation carries its discovery label without client creator claims',
|
||||
() {
|
||||
final tags = buildCreateChannelTags(
|
||||
channelId: _channelId,
|
||||
name: 'Solo',
|
||||
channelType: 'stream',
|
||||
visibility: 'private',
|
||||
labels: const ['buzz.solo'],
|
||||
);
|
||||
expect(tags, contains(equals(['t', 'buzz.solo'])));
|
||||
expect(tags.where((tag) => tag.first == 'P'), isEmpty);
|
||||
final ordinary = buildCreateChannelTags(
|
||||
channelId: _channelId,
|
||||
name: 'Private channel',
|
||||
channelType: 'stream',
|
||||
visibility: 'private',
|
||||
);
|
||||
expect(ordinary.where((tag) => tag.first == 't'), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('builds an ongoing channel without a ttl tag', () {
|
||||
final tags = buildCreateChannelTags(
|
||||
channelId: 'c8c629ae-d35c-44fa-bc39-f6c1816756cc',
|
||||
|
||||
@@ -40,7 +40,65 @@ class FakeChannels extends ChannelsNotifier {
|
||||
];
|
||||
}
|
||||
|
||||
class LabeledChannels extends ChannelsNotifier {
|
||||
@override
|
||||
Future<List<Channel>> build() async => [
|
||||
for (final (id, name, owner, member) in [
|
||||
('filed', 'Filed labeled chat', 'owner', true),
|
||||
('loose', 'Unfiled labeled chat', 'owner', true),
|
||||
('shared', 'Someone else chat', 'other', true),
|
||||
('hidden', 'Inaccessible labeled chat', 'owner', false),
|
||||
])
|
||||
Channel(
|
||||
id: id,
|
||||
name: name,
|
||||
channelType: 'stream',
|
||||
visibility: 'private',
|
||||
labels: const ['buzz.solo'],
|
||||
description: '',
|
||||
createdBy: owner,
|
||||
createdAt: DateTime(2026),
|
||||
memberCount: 2,
|
||||
isMember: member,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('label-only desktop chats appear in projects and unfiled chats', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
myPubkeyProvider.overrideWithValue('owner'),
|
||||
channelsProvider.overrideWith(LabeledChannels.new),
|
||||
meGroupsProvider.overrideWith(
|
||||
(ref) => Stream.value(
|
||||
const MeGroups(
|
||||
groups: [
|
||||
{
|
||||
'id': 'project',
|
||||
'name': 'App work',
|
||||
'defaultTemplateId': '',
|
||||
},
|
||||
],
|
||||
assignments: {'filed': 'project'},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Filed labeled chat'), findsOneWidget);
|
||||
expect(find.text('Unfiled labeled chat'), findsOneWidget);
|
||||
expect(find.text('Someone else chat'), findsNothing);
|
||||
expect(find.text('Inaccessible labeled chat'), findsNothing);
|
||||
expect(find.text('A place to work with your agents'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('empty workspace stays centered behind the New chat keyboard', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
146
mobile/test/features/channels/me/solo_creation_tags_test.dart
Normal file
146
mobile/test/features/channels/me/solo_creation_tags_test.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/features/channels/channels_provider.dart';
|
||||
import 'package:buzz/features/channels/me/me_page.dart';
|
||||
import 'package:buzz/shared/me/me_assistants.dart';
|
||||
import 'package:buzz/shared/me/me_groups.dart';
|
||||
import 'package:buzz/shared/me/me_sync.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _EmptyChannels extends ChannelsNotifier {
|
||||
@override
|
||||
Future<List<Channel>> build() async => [];
|
||||
}
|
||||
|
||||
class _Relay extends RelaySessionNotifier {
|
||||
final NostrEvent profile;
|
||||
final submitted = <NostrEvent>[];
|
||||
_Relay(this.profile);
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> queryRelay(
|
||||
List<NostrFilter> filters, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => [profile];
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => [];
|
||||
|
||||
@override
|
||||
Future<NostrEvent> publish(
|
||||
NostrEvent event, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
submitted.add(event);
|
||||
// Stop at the transport boundary: this test covers the real creation
|
||||
// command, not relay acceptance or the subsequent membership writes.
|
||||
throw StateError('Captured creation command');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'New Solo chat submits its discovery label through the production action',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final owner = nostr.Keys.generate(), agent = nostr.Keys.generate();
|
||||
final digest = SHA256Digest()
|
||||
.process(
|
||||
Uint8List.fromList(
|
||||
utf8.encode('nostr:agent-auth:${agent.public}:'),
|
||||
),
|
||||
)
|
||||
.map((v) => v.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final profile = NostrEvent.fromJson(
|
||||
nostr.Event.from(
|
||||
kind: 0,
|
||||
content: jsonEncode({'display_name': 'Owned agent'}),
|
||||
secretKey: agent.secret,
|
||||
tags: [
|
||||
[
|
||||
'auth',
|
||||
owner.public,
|
||||
'',
|
||||
nostr.Schnorr.sign(secretKey: owner.secret, message: digest),
|
||||
],
|
||||
],
|
||||
).toMap(),
|
||||
);
|
||||
final relay = _Relay(profile);
|
||||
final config = RelayConfig(
|
||||
baseUrl: 'https://relay.test',
|
||||
nsec: owner.nsec,
|
||||
);
|
||||
final sync = MeSync(relay, config, prefs: prefs);
|
||||
const bridge = MethodChannel('buzz/profile_text_editor');
|
||||
var forms = 0;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, (
|
||||
call,
|
||||
) async {
|
||||
if (++forms > 1) return null;
|
||||
return {'name': 'Mobile Solo', 'assistant': agent.public};
|
||||
});
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
bridge,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
meSyncProvider.overrideWithValue(sync),
|
||||
meGroupsProvider.overrideWith(
|
||||
(ref) => Stream.value(const MeGroups()),
|
||||
),
|
||||
meAssistantsProvider.overrideWith((ref) async => []),
|
||||
channelsProvider.overrideWith(_EmptyChannels.new),
|
||||
channelActionsProvider.overrideWith(
|
||||
(ref) => ChannelActions(
|
||||
ref: ref,
|
||||
session: relay,
|
||||
signedEventRelay: SignedEventRelay(
|
||||
session: relay,
|
||||
nsec: owner.nsec,
|
||||
),
|
||||
currentPubkey: owner.public,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
tester
|
||||
.widget<FrostedAppBar>(find.byType(FrostedAppBar))
|
||||
.nativeActions!
|
||||
.firstWhere((action) => action.label == 'New chat')
|
||||
.onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
final command = relay.submitted.single;
|
||||
expect(command.kind, 9007);
|
||||
expect(command.pubkey, owner.public);
|
||||
expect(command.tags, contains(equals(['t', 'buzz.solo'])));
|
||||
expect(command.tags, contains(equals(['channel_type', 'stream'])));
|
||||
expect(command.tags, contains(equals(['visibility', 'private'])));
|
||||
expect(command.tags.where((tag) => tag.first == 'P'), isEmpty);
|
||||
},
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
|
||||
);
|
||||
}
|
||||
67
scripts/contracts/capture-solo-creation.mjs
Normal file
67
scripts/contracts/capture-solo-creation.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
// Capture the current desktop's production create call, without a relay write.
|
||||
// Usage: node --experimental-transform-types scripts/contracts/capture-solo-creation.mjs /path/to/buzz-app
|
||||
import { registerHooks } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import assert from "node:assert/strict";
|
||||
const root = resolve(process.argv[2]);
|
||||
execFileSync("git", ["-C", root, "diff", "--exit-code", "HEAD", "--", "src"]);
|
||||
registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
try {
|
||||
return next(specifier, context);
|
||||
} catch (error) {
|
||||
if (specifier.startsWith(".") && !/\.[a-z]+$/.test(specifier))
|
||||
return next(`${specifier}.ts`, context);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
const { createChannelWrites } = await import(
|
||||
pathToFileURL(`${root}/src/features/relay/channel-writes.ts`)
|
||||
);
|
||||
let creation;
|
||||
const outbox = {
|
||||
supports: () => true,
|
||||
send: (event) => {
|
||||
creation = event;
|
||||
return "captured";
|
||||
},
|
||||
};
|
||||
const reader = {};
|
||||
createChannelWrites(
|
||||
outbox,
|
||||
{},
|
||||
reader,
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
reader,
|
||||
true,
|
||||
).create("11111111-1111-4111-8111-111111111111", "Desktop Solo");
|
||||
assert.equal(creation.kind, 9007);
|
||||
assert.ok(
|
||||
creation.tags.some(([key, value]) => key === "t" && value === "buzz.solo"),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
producer: "https://github.com/block/buzz-app",
|
||||
revision: execFileSync("git", ["-C", root, "rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
creation,
|
||||
creator: "11".repeat(32),
|
||||
identity_tags: [
|
||||
["t", "stream"],
|
||||
["t", "buzz.solo"],
|
||||
["P", "11".repeat(32)],
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
21
test-fixtures/solo-creation.json
Normal file
21
test-fixtures/solo-creation.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"producer": "https://github.com/block/buzz-app",
|
||||
"revision": "64726576ed3b082daa5786e5e7ca6b5a018663a1",
|
||||
"creation": {
|
||||
"kind": 9007,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["h", "11111111-1111-4111-8111-111111111111"],
|
||||
["name", "Desktop Solo"],
|
||||
["visibility", "private"],
|
||||
["channel_type", "stream"],
|
||||
["t", "buzz.solo"]
|
||||
]
|
||||
},
|
||||
"creator": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"identity_tags": [
|
||||
["t", "stream"],
|
||||
["t", "buzz.solo"],
|
||||
["P", "1111111111111111111111111111111111111111111111111111111111111111"]
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user