@amityco/social-plus-vise 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -2
- package/README.md +19 -9
- package/dist/capabilities.js +29 -1
- package/dist/entryState.js +71 -0
- package/dist/experience.js +70 -0
- package/dist/explore.js +1 -1
- package/dist/flow.js +382 -0
- package/dist/humanFormat.js +25 -0
- package/dist/intake.js +117 -0
- package/dist/intelligence/placement.js +2 -1
- package/dist/outcomes.js +126 -28
- package/dist/productExpectations.js +15 -0
- package/dist/requestReadiness.js +99 -0
- package/dist/server.js +566 -19
- package/dist/sidecar.js +5 -0
- package/dist/solutionPath.js +1 -1
- package/dist/tools/compliance.js +752 -125
- package/dist/tools/creative.js +14 -12
- package/dist/tools/design.js +321 -11
- package/dist/tools/experienceCompiler.js +1 -1
- package/dist/tools/experienceSensors.js +1 -1
- package/dist/tools/harness.js +13 -0
- package/dist/tools/integration.js +263 -90
- package/dist/tools/learning.js +1 -3
- package/dist/tools/project.js +963 -66
- package/dist/tools/smoke.js +134 -0
- package/dist/tools/uxHarness.js +54 -6
- package/dist/uikitCustomization.js +3 -1
- package/dist/version.js +7 -3
- package/package.json +1 -1
- package/packages/intelligence/catalog/catalog.schema.json +1 -1
- package/packages/intelligence/catalog/experience-objects.json +2 -2
- package/packages/intelligence/catalog/variants.json +24 -0
- package/rules/event.yaml +3 -0
- package/rules/feed.yaml +251 -0
- package/rules/invitation.yaml +4 -0
- package/rules/live-data.yaml +110 -0
- package/rules/notification-tray.yaml +4 -0
- package/rules/poll.yaml +5 -0
- package/rules/sdk-lifecycle.yaml +559 -0
- package/rules/search.yaml +5 -0
- package/rules/security.yaml +12 -0
- package/rules/story.yaml +5 -0
- package/rules/user-blocking.yaml +5 -0
- package/skills/social-plus-vise/SKILL.md +163 -15
package/dist/outcomes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { experienceBundleFor } from "./experience.js";
|
|
1
2
|
export function hasAnswer(answers, id) {
|
|
2
3
|
const value = answers[id];
|
|
3
4
|
return typeof value === "string" && value.trim() !== "";
|
|
@@ -28,12 +29,54 @@ const LIVE_PATTERNS = [
|
|
|
28
29
|
EVENT_REGEX,
|
|
29
30
|
LIVESTREAM_REGEX,
|
|
30
31
|
];
|
|
32
|
+
export const PLATFORM_FEATURE_GAPS = [
|
|
33
|
+
{
|
|
34
|
+
feature: "Scheduled events & RSVP",
|
|
35
|
+
match: EVENT_REGEX,
|
|
36
|
+
exclude: /\bevents?\s+(?:listener|listeners|handler|handlers|subscription|subscriptions|emitter|emitters|bus|stream|streams|callback|callbacks)\b|\b(?:real-?time|analytics|tracking|telemetry)\s+events?\b/i,
|
|
37
|
+
unavailableOn: ["flutter"],
|
|
38
|
+
availableOn: ["typescript", "ios", "android"],
|
|
39
|
+
objectIds: ["event", "rsvp"],
|
|
40
|
+
sdkSource: "social-plus-sdk/social/events/overview",
|
|
41
|
+
guidance: "The social.plus Flutter SDK does not expose scheduled-event creation, query, management, or RSVP APIs — events are available on the iOS, Android, and TypeScript SDKs. Do not invent an AmityEventRepository / event query in Dart. Choose an alternative: (a) model events as posts in a dedicated community and carry date/RSVP details in post metadata, (b) call the server-side Event REST API (api-reference/event) from your backend and surface results through your own endpoint, or (c) build the event surface on a supported platform (iOS, Android, or TypeScript/web).",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
feature: "Room-based livestreaming (broadcast & room management)",
|
|
45
|
+
match: LIVESTREAM_REGEX,
|
|
46
|
+
exclude: /\blive\s+chat\b|\bvideo\s+(?:call|calling|conferenc)\w*\b/i,
|
|
47
|
+
unavailableOn: ["flutter"],
|
|
48
|
+
availableOn: ["typescript", "ios", "android"],
|
|
49
|
+
objectIds: ["livestream"],
|
|
50
|
+
sdkSource: "use-cases/social/livestream/go-live-and-room-management",
|
|
51
|
+
guidance: "Room-based livestreaming — broadcasting, going live, and room management (RoomRepository.createRoom / getRoom / getRooms / stopRoom) — is NOT in the social.plus Flutter SDK; it is available on the iOS, Android, and TypeScript/web SDKs. The Flutter SDK exposes only the DEPRECATED legacy Stream API (StreamRepository.getStream) for VIEWING existing stream posts — there is no Room API in Dart, so do not invent RoomRepository / createRoom. Options: (a) build the broadcast/host livestream experience on a supported platform (iOS, Android, or TypeScript/web), (b) on Flutter, render an existing legacy stream post read-only via StreamRepository.getStream(streamId) (deprecated — exposes title/status/isLive only), or (c) embed a web livestream view.",
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
export function platformFeatureGapsFor(request, platform) {
|
|
55
|
+
return PLATFORM_FEATURE_GAPS.filter((gap) => gap.unavailableOn.includes(platform) &&
|
|
56
|
+
gap.match.test(request) &&
|
|
57
|
+
!(gap.exclude && gap.exclude.test(request)));
|
|
58
|
+
}
|
|
59
|
+
export function platformFeatureGapsForObjects(objectIds, platform) {
|
|
60
|
+
const present = new Set(objectIds);
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const gap of PLATFORM_FEATURE_GAPS) {
|
|
63
|
+
if (!gap.unavailableOn.includes(platform) || !gap.objectIds)
|
|
64
|
+
continue;
|
|
65
|
+
const objects = gap.objectIds.filter((id) => present.has(id));
|
|
66
|
+
if (objects.length > 0)
|
|
67
|
+
out.push({ gap, objects });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
31
71
|
const FEED_PATTERNS = [
|
|
32
72
|
/\b(social feature|social features|feed|timeline|post list|news feed|create post|create a post|post creation|compose post)\b/,
|
|
33
73
|
];
|
|
34
74
|
const COMMENT_PATTERNS = [
|
|
35
75
|
/\b(comment|comments|reply|replies|thread|discussion thread|comment composer)\b/,
|
|
36
76
|
];
|
|
77
|
+
const REACTION_PATTERNS = [
|
|
78
|
+
/\b(reaction|reactions|react|like|likes|emoji react|upvote)\b/,
|
|
79
|
+
];
|
|
37
80
|
const MODERATION_OUTCOME_PATTERNS = [
|
|
38
81
|
/\b(moderation|report|flag|block user|mute user|hide content|abuse|review queue|admin queue)\b/,
|
|
39
82
|
];
|
|
@@ -61,7 +104,7 @@ const NOTIFICATION_PATTERNS = [
|
|
|
61
104
|
const TROUBLESHOOT_PATTERNS = [/\b(error|broken|crash|not working|fail|timeout|401|403)\b/];
|
|
62
105
|
const VALIDATE_PATTERNS = [/\b(validate|check|correct|setup right|initiali[sz])\b/];
|
|
63
106
|
const SETUP_PATTERNS = [/\b(setup|set up|install|integrate|wire|configure|init sdk|sdk setup|session lifecycle)\b|initialise?s?\b/];
|
|
64
|
-
export const BROAD_SOCIAL_REGEX = /\b(nice|social features|social feature|engagement|community experience)\b|\b(?:
|
|
107
|
+
export const BROAD_SOCIAL_REGEX = /\b(nice|social features|social feature|engagement|community experience)\b|\b(?:feeds?|post|posts|comments?)\b[\s\S]{0,160}\b(?:chat|messaging|profile|followers?|following|social graph)\b|\b(?:chat|messaging|profile|followers?|following|social graph)\b[\s\S]{0,160}\b(?:feeds?|post|posts|comments?)\b/i;
|
|
65
108
|
export const DESIGN_REGEX = /\bdesign token|design tokens|theme|same design|design system|brand/i;
|
|
66
109
|
export function classifyOutcome(request) {
|
|
67
110
|
const normalized = request.toLowerCase();
|
|
@@ -111,10 +154,11 @@ export function classifyOutcome(request) {
|
|
|
111
154
|
}
|
|
112
155
|
export function resolveOutcome(request, answers = {}) {
|
|
113
156
|
const selectedSurface = outcomeFromFeatureSurface(answers.feature_surface);
|
|
114
|
-
|
|
157
|
+
const bareOutcome = classifyOutcome(request);
|
|
158
|
+
if (selectedSurface && (BROAD_SOCIAL_REGEX.test(request) || experienceBundleFor(request) !== undefined || bareOutcome === "unknown")) {
|
|
115
159
|
return selectedSurface;
|
|
116
160
|
}
|
|
117
|
-
return
|
|
161
|
+
return bareOutcome;
|
|
118
162
|
}
|
|
119
163
|
function outcomeFromFeatureSurface(answer) {
|
|
120
164
|
const normalized = (answer ?? "").toLowerCase();
|
|
@@ -424,8 +468,11 @@ const setupLiveData = {
|
|
|
424
468
|
"UI handling for loading, empty, error, insertion, deletion, and modification states",
|
|
425
469
|
],
|
|
426
470
|
implementationRules: () => [
|
|
471
|
+
"Gate the live query on a REACTIVE session-readiness signal (observe the session/auth state, seeded synchronously from its current value) so the query (re-)runs when the session becomes active AFTER the view mounts. The consuming surface usually mounts before login resolves; a one-shot readiness check at mount (onAppear / useEffect([]) / initState / onViewCreated) that returns early and never re-fires leaves the surface permanently empty even though it compiles and vise check is green. Render the not-ready and error states as retryable, not a dead end.",
|
|
427
472
|
"Do not replace Live Object or Live Collection observation with polling unless the docs or user explicitly require it.",
|
|
428
473
|
"Always clean up observers/subscriptions when the owning component, screen, ViewModel, or controller is destroyed.",
|
|
474
|
+
"On iOS/SwiftUI, deinit-based cleanup does NOT run while the owner is retained for the app's lifetime (e.g. a @StateObject view-model in a TabView): for such surfaces prefer .onDisappear / viewWillDisappear so the observer is released when the user leaves the surface, not only when the owner deallocates.",
|
|
475
|
+
"vise's deterministic events/stories live-collection checks verify reactivity + observer cleanup only and pass on the ABSENCE of a violation (not on positive presence) — independently confirm the surface itself is wired: events need getEvents/getRSVPs + RSVP create/update; stories need getActiveStoriesByTarget + a ring/viewer.",
|
|
429
476
|
"Handle loading and error states from the live data API before rendering data as ready.",
|
|
430
477
|
"For collections, use insertion/deletion/modification changes for targeted UI updates instead of assuming a full list replacement.",
|
|
431
478
|
],
|
|
@@ -442,7 +489,11 @@ const setupLiveData = {
|
|
|
442
489
|
],
|
|
443
490
|
},
|
|
444
491
|
{
|
|
445
|
-
step: "
|
|
492
|
+
step: "Drive the subscription off a reactive session-readiness signal (observe session/auth state, seeded synchronously from the current value) so the query (re-)runs when the session becomes active — the surface usually mounts before login resolves, and a one-shot readiness check at mount that never re-fires leaves it permanently empty.",
|
|
493
|
+
evidence: ["social-plus-sdk/getting-started/authentication", liveDataPlatformPath(ctx.platform)],
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
step: "Implement the subscription in the owning lifecycle scope and render loading, empty, error, and data states (with not-ready and error states rendered as retryable, not a dead end).",
|
|
446
497
|
evidence: ["requiredInputs.lifecycle owner for subscribe/unsubscribe cleanup", "implementationRules.loading-error-states"],
|
|
447
498
|
},
|
|
448
499
|
{
|
|
@@ -521,7 +572,7 @@ const addFeed = {
|
|
|
521
572
|
questions.push({
|
|
522
573
|
id: "feed_scope",
|
|
523
574
|
question: "What scope should the first feed use?",
|
|
524
|
-
why: "Feed APIs and query shape depend on whether the content is global, user-specific, or community-specific.",
|
|
575
|
+
why: "Feed APIs and query shape depend on whether the content is global, user-specific, or community-specific. A general social feed defaults to global (app-wide); choose community only when the request is explicitly community-scoped — and note a community feed additionally requires the user to be a member before they can post.",
|
|
525
576
|
required: true,
|
|
526
577
|
blocksImplementationWhenMissing: true,
|
|
527
578
|
options: ["global", "user", "community"],
|
|
@@ -529,10 +580,10 @@ const addFeed = {
|
|
|
529
580
|
questions.push({
|
|
530
581
|
id: "feed_target",
|
|
531
582
|
question: "What concrete feed target should reads and post creation use?",
|
|
532
|
-
why: "
|
|
583
|
+
why: "Prefer the global (app-wide) feed or the current user's feed for a general social feed — both are ID-free, need no membership to post, and are the safe default. The no-invent rule applies to IDs: never invent a communityId, targetId, or feedId. Choose a community target only when the request names a community-scoped surface, and then source it from route/app state, SDK discovery, user selection, or a create flow.",
|
|
533
584
|
required: true,
|
|
534
585
|
blocksImplementationWhenMissing: true,
|
|
535
|
-
options: ["
|
|
586
|
+
options: ["app-defined/global feed", "current user's feed", "selected user's feed", "community selected from route/discovery/create flow", "ask user at runtime"],
|
|
536
587
|
});
|
|
537
588
|
questions.push({
|
|
538
589
|
id: "target_screen_or_route",
|
|
@@ -565,6 +616,14 @@ const addFeed = {
|
|
|
565
616
|
blocksImplementationWhenMissing: false,
|
|
566
617
|
options: ["all SDK-available creation types", "text only", "text + media", "text + media + poll", "custom subset"],
|
|
567
618
|
});
|
|
619
|
+
questions.push({
|
|
620
|
+
id: "auth_secure_mode",
|
|
621
|
+
question: "Is this integration production-bound (secure-mode auth with a server-issued token) or dev/testing only (unsecure auth, no token)?",
|
|
622
|
+
why: "Unsecure (development) auth — login without a server-issued auth token — lets any client impersonate any user. Fine for local testing, but production MUST use secure mode (a server-issued auth token + a renewal handler). `vise check` surfaces a security advisory whenever the code uses unsecure auth, regardless of this answer.",
|
|
623
|
+
required: false,
|
|
624
|
+
blocksImplementationWhenMissing: false,
|
|
625
|
+
options: ["production — secure mode (server-issued auth token)", "dev/testing only — unsecure auth (no token)"],
|
|
626
|
+
});
|
|
568
627
|
return filterAnswered(ctx.answers, questions);
|
|
569
628
|
},
|
|
570
629
|
requiredInputs: () => [
|
|
@@ -572,7 +631,7 @@ const addFeed = {
|
|
|
572
631
|
"lifecycle owner for subscribe/unsubscribe cleanup",
|
|
573
632
|
"feature choice: feed, comments, profile, community, chat, notifications, or another social surface",
|
|
574
633
|
"feed scope: global, user, or community",
|
|
575
|
-
"concrete feed target for reads and post creation:
|
|
634
|
+
"concrete feed target for reads and post creation: app-defined/global feed, current user feed, selected user target, or community selected from route/discovery/create flow",
|
|
576
635
|
"target screen or route for the feed UI",
|
|
577
636
|
],
|
|
578
637
|
implementationRules: () => [
|
|
@@ -606,6 +665,13 @@ const addFeed = {
|
|
|
606
665
|
"social-plus-sdk/core-concepts/realtime-communication/live-objects-collections/overview",
|
|
607
666
|
],
|
|
608
667
|
},
|
|
668
|
+
{
|
|
669
|
+
step: "Drive the feed query off a reactive session-readiness signal (observe session/auth state, seeded synchronously) so it (re-)runs when the session becomes active — the feed screen usually mounts before login resolves, and a one-shot readiness check at mount (onAppear / useEffect([]) / initState) that never re-fires leaves the feed permanently empty even though it compiles and vise check is green. Render the not-ready and error states as retryable, not a dead end.",
|
|
670
|
+
evidence: [
|
|
671
|
+
"social-plus-sdk/getting-started/authentication",
|
|
672
|
+
"requiredInputs.target screen or route",
|
|
673
|
+
],
|
|
674
|
+
},
|
|
609
675
|
{
|
|
610
676
|
step: "Wire feed pagination: after the initial page loads, expose a 'Load more' action (button, scroll trigger, or infinite-query) that calls the collection's next-page method (loadMore() / nextPage() / onNextPage()). Check hasMore / hasNextPage before showing the control so it disappears when the list is exhausted. Use only opaque cursor tokens returned by the SDK — never construct numeric page offsets. On React/TypeScript, useAmityElement with an infinite-query hook is the idiomatic pattern; on Flutter, a ScrollController + loadMore callback; on Android, PagingData with a LazyColumn scroll trigger.",
|
|
611
677
|
evidence: [
|
|
@@ -633,7 +699,11 @@ const addFeed = {
|
|
|
633
699
|
{ step: "Reuse the host app's existing visual system for the social surface.", evidence: designEvidence },
|
|
634
700
|
{ step: "Implement loading, empty, error, and data states.", evidence: ["implementationRules.file-specific edits"] },
|
|
635
701
|
{
|
|
636
|
-
step: "In post card renderers,
|
|
702
|
+
step: "In post card renderers, render the normal post body/description/caption when it is present. Do not make `postId` the primary visible content, and do not invent a required title model: `post.title`, `metadata.title`, or custom-payload title fields are optional product choices, not requirements for a correct social.plus post renderer.",
|
|
703
|
+
evidence: ["social-plus-sdk/social/content-management/posts/creation/text-post", "capabilityAvailability.available"],
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
step: "In post card renderers, resolve each media type from the parent post OR its childrenPosts — in a feed the parent is usually dataType 'text' and the image/video/poll/clip/room rides on a child post. Handle at minimum: text, image, video, file, poll, clip, room. Do not render placeholder labels like '[Image post]' or '[Poll post]'; read the SDK data/accessors and render the actual content. Do NOT gate media on the parent's dataType alone (e.g. post.dataType === 'poll') — that never matches a text parent and the content silently never renders. Read child media from the RESOLVED children accessor (post.childrenPosts, whose entries are Post objects exposing .dataType / getImageInfo() etc.) — or, if you only have the raw `post.children` array, resolve the child-post IDs first (getPost(childId) / getPostByIds). Do NOT cast `post.children` to post objects and read `.dataType` on it: `post.children` holds child-post ID STRINGS, so `.dataType` is undefined there and attachments silently never render — a resolution bug a presence check (the code merely references children) cannot catch. When rendering the text body itself (post or comment), apply @mention highlights if metadata carries mention entries ({ type: 'user', index, length, userId }, length excluding the '@'): wrap each [index, index + length + 1] span in a styled element and resolve userId to a display name, rather than printing raw text. Pass mentionees on create so mentioned users are notified.",
|
|
637
707
|
evidence: ["social-plus-sdk/social/content-management/posts/creation/text-post", "intake.feed_post_type_scope", "capabilityAvailability.available"],
|
|
638
708
|
},
|
|
639
709
|
{
|
|
@@ -658,6 +728,15 @@ const addFeed = {
|
|
|
658
728
|
evidence: ["social-plus-sdk/social/content-management/comments/creation/text-comment"],
|
|
659
729
|
},
|
|
660
730
|
] : []),
|
|
731
|
+
...(REACTION_PATTERNS.some((p) => p.test(ctx.request.toLowerCase())) ? [
|
|
732
|
+
{
|
|
733
|
+
step: "If post cards display a reaction/like control, wire it end to end via ReactionRepository — not a static icon. Add on tap with addReaction(referenceType: 'post', referenceId: post.postId, reactionName) and remove with removeReaction(...) on un-tap (reaction names are case-sensitive and app-defined, e.g. 'like'). Source the count and the current user's state from the post's own reaction data (post.reactionsCount / post.myReactions) so the UI reflects optimistic toggles, and use ReactionRepository.getReactions({ referenceId, referenceType: 'post' }) only when you need the per-user breakdown. A heart/like button with no add/remove wiring (or a hardcoded count) is incomplete — users expect the count to update when they react.",
|
|
734
|
+
evidence: [
|
|
735
|
+
"social-plus-sdk/core-concepts/content-handling/reactions.mdx",
|
|
736
|
+
"social-plus-sdk/core-concepts/realtime-communication/live-objects-collections/overview",
|
|
737
|
+
],
|
|
738
|
+
},
|
|
739
|
+
] : []),
|
|
661
740
|
...(/notification|bell|tray|inbox/i.test(ctx.request) ? [
|
|
662
741
|
{
|
|
663
742
|
step: "If the design includes a notification bell or notification center, implement it with the notificationTray SDK: getNotificationTrayItems (live collection), markItemsSeen on open, and an unread badge sourced from getNotificationTraySeen.",
|
|
@@ -700,6 +779,7 @@ const addFeed = {
|
|
|
700
779
|
"posts.status-filtered",
|
|
701
780
|
"pagination.cursor-opaque",
|
|
702
781
|
"unread.server-synced",
|
|
782
|
+
"feed.post-body-rendering",
|
|
703
783
|
"feed.rich-post-rendering",
|
|
704
784
|
"feed.rich-post-composer-scope",
|
|
705
785
|
"feed.post-type-scope-explicit",
|
|
@@ -711,8 +791,8 @@ const addFeed = {
|
|
|
711
791
|
"profile.social-counts",
|
|
712
792
|
],
|
|
713
793
|
stopConditions: (ctx) => filterStops(ctx.answers, [
|
|
714
|
-
{ id: "feed_target", text: "The concrete feed target is unknown;
|
|
715
|
-
{ id: "feed_target", text: "If post creation is in scope, the write target must be
|
|
794
|
+
{ id: "feed_target", text: "The concrete feed target is unknown; never invent a communityId, targetId, or feedId. For a general social feed, default to the ID-free app-wide global feed or the current user's feed; reserve a specific community/target for an explicitly community-scoped request." },
|
|
795
|
+
{ id: "feed_target", text: "If post creation is in scope, the write target must be a concrete feed — the global or current-user feed (no id, no membership needed), or a community/selected target the user or app state provides (never a hardcoded or invented id)." },
|
|
716
796
|
]),
|
|
717
797
|
resolvePlan: () => [
|
|
718
798
|
{
|
|
@@ -1009,9 +1089,16 @@ const addChat = {
|
|
|
1009
1089
|
{
|
|
1010
1090
|
id: "channel_source",
|
|
1011
1091
|
question: "How are channels/conversations created or discovered?",
|
|
1012
|
-
why: "
|
|
1092
|
+
why: "For a chat inbox/browse surface, prefer the current user's own channels — queryChannels()/getChannels with membership:'member' is ID-free and needs no channelId. Creating a 1:1 DM needs a recipient userId and a group needs member userIds (createChannel); opening a specific existing channel uses its channelId from the inbox query. The no-invent rule applies to IDs: never invent a channelId or member list.",
|
|
1013
1093
|
required: true,
|
|
1014
1094
|
blocksImplementationWhenMissing: true,
|
|
1095
|
+
options: [
|
|
1096
|
+
"my inbox (queryChannels()/getChannels, membership:'member')",
|
|
1097
|
+
"user/app selection of an existing channel",
|
|
1098
|
+
"create 1:1 DM (needs recipient userId)",
|
|
1099
|
+
"create group channel (needs member userIds)",
|
|
1100
|
+
"ask user at runtime",
|
|
1101
|
+
],
|
|
1015
1102
|
},
|
|
1016
1103
|
{
|
|
1017
1104
|
id: "message_capabilities",
|
|
@@ -1081,8 +1168,12 @@ const addChat = {
|
|
|
1081
1168
|
step: "Implement channel query/creation and message observation with lifecycle cleanup.",
|
|
1082
1169
|
evidence: ["social-plus-sdk/chat/channels", "social-plus-sdk/chat/messages"],
|
|
1083
1170
|
},
|
|
1171
|
+
{
|
|
1172
|
+
step: "Drive the channel-list (and message) query off a reactive session-readiness signal (observe session/auth state, seeded synchronously) so it (re-)runs when the session becomes active — the inbox/thread usually mounts before login resolves, and a one-shot readiness check at mount (onAppear / useEffect([]) / initState) that never re-fires leaves chat permanently empty even though it compiles and vise check is green. Render the not-ready and error states as retryable, not a dead end.",
|
|
1173
|
+
evidence: ["social-plus-sdk/getting-started/authentication", "social-plus-sdk/chat/channels"],
|
|
1174
|
+
},
|
|
1084
1175
|
{ step: "Reuse the host app's existing visual system for chat UI.", evidence: designEvidence },
|
|
1085
|
-
{ step: "Add message send with error handling and auth gate. Observe each message's syncState and surface failed sends (error state) with a retry/delete affordance instead of rendering optimistic success unconditionally; clean up unrecoverable failures (e.g. deleteFailedMessages) on init.", evidence: ["implementationRules.file-specific edits"] },
|
|
1176
|
+
{ step: "Add message send with error handling and auth gate. Observe each message's syncState and surface failed sends (error state) with a retry/delete affordance instead of rendering optimistic success unconditionally; clean up unrecoverable failures (e.g. deleteFailedMessages) on init. For a community channel, sending may require joining first (joinChannel) — surface a join affordance so a non-member's send does not silently fail.", evidence: ["implementationRules.file-specific edits"] },
|
|
1086
1177
|
{ step: "Render the chat inbox from SDK channel queries/live collections. Show unread counts/badges from the SDK on channel rows or the chat tab (`channel.getUnreadCount()`, `getSubChannelsUnreadCount()`, or `AmityCoreClient.observeUserUnread()` / `getTotalChannelUnread()`), and declare channel-list sorting explicitly (typically last activity descending) so recency cannot be reversed by UI defaults.", evidence: ["social-plus-sdk/chat/channels", "intake.chat_inbox_scope", "capabilityAvailability.available"] },
|
|
1087
1178
|
{ step: "Declare message order explicitly with `AmityMessageQuerySortOption.FIRST_CREATED` or `LAST_CREATED` so the message thread cannot invert by relying on defaults.", evidence: ["social-plus-sdk/chat/messages"] },
|
|
1088
1179
|
{ step: "Wire read receipts and typing indicators if required — and mark the channel/messages read on open (channel.markAsRead() / message.markRead()) so the server-side unread count actually decrements. Reading the unread count without ever marking read leaves the badge stuck. Note: the MarkerSyncEngine receipt-sync API (startMessageReceiptSync/stopMessageReceiptSync) is deprecated and being sun-set — track read state with the unread count + markRead() instead.", evidence: ["requiredInputs.read receipt and typing indicator requirements"] },
|
|
@@ -1164,10 +1255,17 @@ const addCommunity = {
|
|
|
1164
1255
|
},
|
|
1165
1256
|
{
|
|
1166
1257
|
id: "community_target",
|
|
1167
|
-
question: "Where
|
|
1168
|
-
why: "
|
|
1258
|
+
question: "Where do the communities come from — a discovery query, a create flow, user selection, or an app route/detail state?",
|
|
1259
|
+
why: "For a browse/join or create surface this is ID-free: discover communities via getCommunities/searchCommunities/getRecommendedCommunities/getTrendingCommunities (a query, no communityId), or create one with createCommunity (the id is server-generated). A concrete inbound community object/id belongs only to a detail/members or membership-management surface of a specific existing community — source it from route/app state, user selection, discovery, or the create-flow return value. The no-invent rule applies to IDs: never invent a communityId.",
|
|
1169
1260
|
required: true,
|
|
1170
1261
|
blocksImplementationWhenMissing: true,
|
|
1262
|
+
options: [
|
|
1263
|
+
"discovery query (getCommunities/searchCommunities/getRecommendedCommunities/getTrendingCommunities)",
|
|
1264
|
+
"createCommunity flow (server-generated id)",
|
|
1265
|
+
"user selection of an existing community",
|
|
1266
|
+
"route/app state for an existing community",
|
|
1267
|
+
"ask user at runtime",
|
|
1268
|
+
],
|
|
1171
1269
|
},
|
|
1172
1270
|
{
|
|
1173
1271
|
id: "lifecycle_owner",
|
|
@@ -1189,7 +1287,7 @@ const addCommunity = {
|
|
|
1189
1287
|
requiredInputs: () => [
|
|
1190
1288
|
"community surface: create, browse/join, detail+members, or membership management",
|
|
1191
1289
|
"community privacy model: public (instant) vs private (join request)",
|
|
1192
|
-
"
|
|
1290
|
+
"natural community source (route/app state, discovery, user selection, or create flow)",
|
|
1193
1291
|
"lifecycle owner for community/member observer cleanup",
|
|
1194
1292
|
"target screen or component",
|
|
1195
1293
|
],
|
|
@@ -1206,8 +1304,8 @@ const addCommunity = {
|
|
|
1206
1304
|
: ["requiredInputs.design token or theme source file"];
|
|
1207
1305
|
return [
|
|
1208
1306
|
{
|
|
1209
|
-
step: "Confirm the community surface, privacy model, community
|
|
1210
|
-
evidence: ["requiredInputs.community surface", "requiredInputs.community privacy model", "requiredInputs.
|
|
1307
|
+
step: "Confirm the community surface, privacy model, natural community source, and target screen before writing code.",
|
|
1308
|
+
evidence: ["requiredInputs.community surface", "requiredInputs.community privacy model", "requiredInputs.natural community source"],
|
|
1211
1309
|
},
|
|
1212
1310
|
{
|
|
1213
1311
|
step: "Implement the community/member query with the platform's Live Collection (AmityCommunityRepository / getMembers), with cleanup on the lifecycle owner.",
|
|
@@ -1230,13 +1328,11 @@ const addCommunity = {
|
|
|
1230
1328
|
"community.avatar-from-sdk",
|
|
1231
1329
|
"community.display-name-from-sdk",
|
|
1232
1330
|
"moderation.role-gated-action",
|
|
1233
|
-
"validate_setup",
|
|
1234
|
-
"run_sensors",
|
|
1235
1331
|
],
|
|
1236
1332
|
stopConditions: (ctx) => filterStops(ctx.answers, [
|
|
1237
1333
|
{ id: "community_scope", text: "The community surface is unknown; do not assume create vs browse vs members vs management." },
|
|
1238
1334
|
{ id: "community_privacy", text: "The privacy model is unknown; public (instant) and private (join-request) flows differ and must not be guessed." },
|
|
1239
|
-
{ id: "community_target", text: "The community
|
|
1335
|
+
{ id: "community_target", text: "The natural community source is unknown; do not invent a communityId." },
|
|
1240
1336
|
{ id: "lifecycle_owner", text: "The lifecycle owner for community/member observer cleanup is unknown." },
|
|
1241
1337
|
]),
|
|
1242
1338
|
resolvePlan: () => [
|
|
@@ -1291,10 +1387,17 @@ const addFollow = {
|
|
|
1291
1387
|
},
|
|
1292
1388
|
{
|
|
1293
1389
|
id: "follow_target",
|
|
1294
|
-
question: "
|
|
1295
|
-
why: "
|
|
1390
|
+
question: "Whose social graph is this — the current user's own (follower/following lists, counts), another user's profile, or a follow/unfollow button (always another user)?",
|
|
1391
|
+
why: "Scope this to the surface. For the current user's OWN follower/following lists, counts, and relationship state, the ID-free default is getCurrentUser()/client.userId — pass that own id to getFollowInfo (counts) and getFollowers/getFollowings (lists); the SDK routes those to the ID-free /me endpoint when the id is your own, so no external userId is needed. A follow/unfollow BUTTON always targets ANOTHER user, so a concrete target userId is required there (do not default it to self); viewing another user's profile/lists likewise needs that user's id. The no-invent rule applies to IDs: never invent a userId.",
|
|
1296
1392
|
required: true,
|
|
1297
1393
|
blocksImplementationWhenMissing: true,
|
|
1394
|
+
options: [
|
|
1395
|
+
"current user, own graph (getCurrentUser()/client.userId — for own lists/counts)",
|
|
1396
|
+
"another user's profile (their userId — for viewing their lists/counts)",
|
|
1397
|
+
"follow/unfollow button target (another user's userId — required, never self)",
|
|
1398
|
+
"route param userId",
|
|
1399
|
+
"ask user at runtime",
|
|
1400
|
+
],
|
|
1298
1401
|
},
|
|
1299
1402
|
{
|
|
1300
1403
|
id: "lifecycle_owner",
|
|
@@ -1360,8 +1463,6 @@ const addFollow = {
|
|
|
1360
1463
|
"profile.identity-from-sdk",
|
|
1361
1464
|
"profile.avatar-from-sdk",
|
|
1362
1465
|
"profile.social-counts",
|
|
1363
|
-
"validate_setup",
|
|
1364
|
-
"run_sensors",
|
|
1365
1466
|
],
|
|
1366
1467
|
stopConditions: (ctx) => filterStops(ctx.answers, [
|
|
1367
1468
|
{ id: "follow_surface", text: "The social-graph surface is unknown; do not assume follow button vs lists vs blocked users." },
|
|
@@ -1456,9 +1557,6 @@ const addNotifications = {
|
|
|
1456
1557
|
"notifications.tray-live",
|
|
1457
1558
|
"notifications.mark-seen",
|
|
1458
1559
|
"notifications.preferences-respected",
|
|
1459
|
-
"this is in-app tray, not push setup",
|
|
1460
|
-
"validate_setup",
|
|
1461
|
-
"run_sensors",
|
|
1462
1560
|
],
|
|
1463
1561
|
stopConditions: (ctx) => filterStops(ctx.answers, [
|
|
1464
1562
|
{ id: "notification_surface", text: "The notification surface is unknown; do not assume tray vs badge vs settings." },
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export const PRODUCT_EXPECTATION_TITLES = {
|
|
2
2
|
"feed.target-resolved": "Feed target comes from app state",
|
|
3
|
+
"feed.source-used": "Feed source uses SDK post APIs",
|
|
3
4
|
"feed.ui-states-present": "Feed renders loading, empty, and error states",
|
|
4
5
|
"feed.pagination-wired": "Feed pagination is wired",
|
|
5
6
|
"moderation.affordance-present": "UGC moderation affordance is present",
|
|
6
7
|
"posts.status-filtered": "Post queries filter unsafe statuses",
|
|
7
8
|
"pagination.cursor-opaque": "Pagination cursors stay opaque",
|
|
8
9
|
"unread.server-synced": "Unread counts use the server-synced stream",
|
|
10
|
+
"feed.post-body-rendering": "Feed renders post body content",
|
|
9
11
|
"feed.rich-post-rendering": "Feed renders rich post types",
|
|
10
12
|
"feed.rich-post-composer-scope": "Feed composer surfaces rich post scope",
|
|
11
13
|
"feed.post-type-scope-explicit": "Feed post-type scope is explicit",
|
|
@@ -45,6 +47,12 @@ const platformBindings = (expectationId, sensorsByPlatform) => Object.entries(se
|
|
|
45
47
|
platform,
|
|
46
48
|
})));
|
|
47
49
|
export const PRODUCT_EXPECTATION_BINDINGS = [
|
|
50
|
+
...platformBindings("feed.source-used", {
|
|
51
|
+
typescript: "typescript.feed.source-used",
|
|
52
|
+
"react-native": "react-native.feed.source-used",
|
|
53
|
+
flutter: "flutter.feed.source-used",
|
|
54
|
+
ios: "ios.feed.source-used",
|
|
55
|
+
}),
|
|
48
56
|
...platformBindings("feed.target-resolved", {
|
|
49
57
|
typescript: ["typescript.feed.target.literal", "typescript.feed.target-type-explicit"],
|
|
50
58
|
"react-native": ["react-native.feed.target.literal", "react-native.feed.target-type-explicit"],
|
|
@@ -114,6 +122,13 @@ export const PRODUCT_EXPECTATION_BINDINGS = [
|
|
|
114
122
|
flutter: "flutter.unread.subscribed-not-counted",
|
|
115
123
|
ios: "ios.unread.subscribed-not-counted",
|
|
116
124
|
}),
|
|
125
|
+
...platformBindings("feed.post-body-rendering", {
|
|
126
|
+
typescript: "typescript.feed.post-body-rendered",
|
|
127
|
+
"react-native": "react-native.feed.post-body-rendered",
|
|
128
|
+
android: "android.feed.post-body-rendered",
|
|
129
|
+
flutter: "flutter.feed.post-body-rendered",
|
|
130
|
+
ios: "ios.feed.post-body-rendered",
|
|
131
|
+
}),
|
|
117
132
|
{
|
|
118
133
|
expectationId: "feed.rich-post-rendering",
|
|
119
134
|
sensorId: "typescript.feed.post-datatype-handled",
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export function clarifyDimensions(input) {
|
|
2
|
+
const dims = [];
|
|
3
|
+
if (!input.platformDetected) {
|
|
4
|
+
dims.push("platform");
|
|
5
|
+
}
|
|
6
|
+
if (input.outcome === "unknown" && !input.creativeSelected && !input.uikitGuidedUnknown) {
|
|
7
|
+
dims.push("experience");
|
|
8
|
+
}
|
|
9
|
+
return dims;
|
|
10
|
+
}
|
|
11
|
+
const PLATFORM_QUESTION = {
|
|
12
|
+
dimension: "platform",
|
|
13
|
+
ask: "No social.plus SDK platform was detected in this repo. Which target app/platform should receive social.plus, and where is that app shell?",
|
|
14
|
+
why: "Vise validates real SDK code, not an abstract idea. A static or platform-unknown repo must be scaffolded or pointed at a real web TypeScript/React, React Native, Flutter, iOS, or Android surface before implementation can be verified.",
|
|
15
|
+
requiredDecision: "Target platform plus app/surface path to build in.",
|
|
16
|
+
options: [
|
|
17
|
+
"web TypeScript/React: package.json with @amityco/ts-sdk and a route/component to mount",
|
|
18
|
+
"React Native: app package plus screen/component path",
|
|
19
|
+
"Flutter: pubspec.yaml app plus widget/route path",
|
|
20
|
+
"iOS: Xcode/Swift package app plus view/module path",
|
|
21
|
+
"Android: Gradle app module plus Activity/Fragment/Compose screen path",
|
|
22
|
+
],
|
|
23
|
+
answerFormat: "Tell the agent the target platform and app shell path; scaffold the platform first when it does not exist, then re-run `vise inspect` and `vise plan`.",
|
|
24
|
+
examples: [
|
|
25
|
+
"web TypeScript/React in apps/web, mount at /social",
|
|
26
|
+
"Android app module :app, add the surface to MainActivity/Compose navigation",
|
|
27
|
+
"Flutter app in mobile/, add the surface under lib/features/social/",
|
|
28
|
+
],
|
|
29
|
+
doNot: [
|
|
30
|
+
"Do not paste SDK code into a static site before a platform has been chosen.",
|
|
31
|
+
"Do not assume web just because package.json is absent.",
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
const EXPERIENCE_QUESTION = {
|
|
35
|
+
dimension: "experience",
|
|
36
|
+
routeTo: "vise creative",
|
|
37
|
+
ask: "The request did not name a buildable social experience. Which EI experience should be selected, or is this a no-fit for the current catalog?",
|
|
38
|
+
why: "A broad request like 'add social.plus features' needs a product direction before planning. Vise must not keyword-guess the experience; the host agent should use the EI candidate menu as advisory data and show the choice before accepting one.",
|
|
39
|
+
requiredDecision: "Selected EI experience variant, or explicit no-fit.",
|
|
40
|
+
answerFormat: "Run `vise creative . --request \"<intent>\"`, present the EI choice block, then run `vise creative accept . --variant <id|none> --rationale \"<why>\" --confidence high|medium|low`.",
|
|
41
|
+
examples: [
|
|
42
|
+
"community-first because the app needs topic/member spaces before feeds and chat",
|
|
43
|
+
"discovery-first because the core goal is browsing and finding social content",
|
|
44
|
+
"none because none of the surfaced candidates match the requested product motion",
|
|
45
|
+
],
|
|
46
|
+
doNot: [
|
|
47
|
+
"Do not accept a variant before showing the recommendation, alternatives, tradeoffs, expected surfaces, and no-fit option.",
|
|
48
|
+
"Do not infer the experience from keywords alone.",
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
export function clarifyGuidance(dims) {
|
|
52
|
+
if (dims.length === 0) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const resolve = [];
|
|
56
|
+
if (dims.includes("platform")) {
|
|
57
|
+
resolve.push(PLATFORM_QUESTION);
|
|
58
|
+
}
|
|
59
|
+
if (dims.includes("experience")) {
|
|
60
|
+
resolve.push(EXPERIENCE_QUESTION);
|
|
61
|
+
}
|
|
62
|
+
const reasons = [
|
|
63
|
+
dims.includes("platform") ? "no SDK platform is detected" : undefined,
|
|
64
|
+
dims.includes("experience") ? "no buildable social feature is classified" : undefined,
|
|
65
|
+
].filter((reason) => Boolean(reason));
|
|
66
|
+
const decisionChecklist = [
|
|
67
|
+
dims.includes("platform") ? "target platform/app shell and mount route or screen" : undefined,
|
|
68
|
+
dims.includes("experience") ? "selected EI experience, alternatives considered, or explicit no-fit" : undefined,
|
|
69
|
+
"after the request is classifiable: social.plus region (US/EU/SG), API-key env/config placeholder, identity source, feature scope, and route/surface placement",
|
|
70
|
+
"if the request is prebuilt/standard UI shaped: explicit solution_path=sdk|uikit|hybrid before hand-rolling UI",
|
|
71
|
+
].filter((item) => Boolean(item));
|
|
72
|
+
const nextStepParts = [
|
|
73
|
+
"Stop before editing code and resolve `clarify.resolve` with the user",
|
|
74
|
+
dims.includes("experience") ? "run `vise creative`, present the EI choice block, then accept the chosen experience with rationale/confidence" : undefined,
|
|
75
|
+
dims.includes("platform") ? "confirm + scaffold the target platform/app shell (do not invent an integration on an undetected platform)" : undefined,
|
|
76
|
+
"then re-run `vise plan`.",
|
|
77
|
+
].filter((part) => Boolean(part));
|
|
78
|
+
return {
|
|
79
|
+
why: `This request is not yet specific enough to produce a buildable plan (${reasons.join("; ")}).`,
|
|
80
|
+
resolve,
|
|
81
|
+
decisionChecklist,
|
|
82
|
+
unattended: {
|
|
83
|
+
status: "blocked",
|
|
84
|
+
mode: "handoff-required",
|
|
85
|
+
reason: "Blocking clarification is required before implementation can be planned safely.",
|
|
86
|
+
instruction: "If the user is not available, stop and return the clarify.resolve questions plus decisionChecklist. Do not choose defaults, scaffold speculative code, or proceed to init/build.",
|
|
87
|
+
allowedActions: [
|
|
88
|
+
"read-only inspect/plan output",
|
|
89
|
+
"run `vise creative` only to prepare an EI choice menu when the experience dimension is unresolved",
|
|
90
|
+
],
|
|
91
|
+
resumeWhen: [
|
|
92
|
+
"the target platform/app shell is selected and scaffolded when required",
|
|
93
|
+
"the EI experience or no-fit decision is selected when required",
|
|
94
|
+
"a fresh `vise plan` no longer returns this clarify block",
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
nextStep: nextStepParts.join(" — "),
|
|
98
|
+
};
|
|
99
|
+
}
|