From 89a2315d0a06ce926a83019958a5922b0a7d1b05 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 13:46:47 -0400 Subject: [PATCH 1/7] npub identity displays for mention, member, and workflow surfaces PR-D2 of the npub identity display standardization (stacks on PR-D1's truncateNpub/PubKey foundation). ## Summary - Mention identity: mentionDisplay/markdown render compact npubs (truncateNpub); copied-mention and clipboard qualification keeps byte-exact identity roundtrips for both npub and legacy hex-truncated chips, rejecting wrong/missing/tampered key qualification. - Member/participant surfaces (channels, community members, huddle, projects, pulse, search, sidebar, tray, notifications): identity fallbacks render compact npubs; titles/aria stay machine-readable full labels. - Profile labels: resolveUserLabel and formatOwnerLabel fall back to compact npub (never raw hex); profile panel/popover identity rows and copy actions follow. - Workflows: author picker secondary labels, step destination keys, and trigger author references render compact npubs while event/blob IDs keep truncatePubkey; roles/templates pass through. - Avatar initials: key-form labels derive initials from the key tail (validated full npub or exact compact npub shape over the bech32 alphabet) so key-fallback avatars stay distinct; authored names that merely resemble npubs keep name-path initials. - Unit tests across all touched surfaces pin npub compacts, invalid-key behavior, and legacy-hex chip roundtrips; e2e specs assert npub compacts for author options, mention chips, hover identity, and profile owner rows. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src/app/useTrayMenu.ts | 4 +- .../agents/ui/PersonaShareRecipients.tsx | 4 +- .../src/features/channels/lib/memberUtils.ts | 4 +- .../channels/ui/AddMemberSearchResultRow.tsx | 6 +- .../channels/ui/ChannelMemberInviteCard.tsx | 10 +-- .../features/channels/ui/MembersSidebar.tsx | 6 +- .../channels/ui/MembersSidebarMemberCard.tsx | 4 +- .../channels/ui/useChannelAgentSessions.ts | 4 +- .../community-members/ui/AddMemberDialog.tsx | 4 +- .../ui/CommunityMembersCard.tsx | 4 +- .../ui/CommunityMembersSettingsCard.tsx | 10 ++- .../ui/ConfirmRemoveDialog.tsx | 4 +- .../useCommunityJoinAlerts.test.mjs | 7 ++- .../forum/ui/ForumComposer.lifecycle.test.mjs | 3 + .../forum/ui/useForumMentionPreparation.ts | 4 +- .../features/home/ui/RecentNotesSection.tsx | 5 +- .../features/huddle/components/HuddleBar.tsx | 4 +- .../huddle/components/ParticipantList.tsx | 4 +- .../features/huddle/lib/huddleChannelName.ts | 4 +- .../messages/lib/formatTimelineMessages.ts | 4 +- .../messages/lib/mentionCandidates.ts | 4 +- .../messages/lib/mentionClipboard.test.mjs | 35 ++++++++--- .../features/messages/lib/mentionClipboard.ts | 16 ++++- .../features/messages/lib/mentionRanking.ts | 4 +- .../lib/normalizeMentionClipboard.test.mjs | 12 ++++ .../messages/lib/timelineMentionCopy.test.mjs | 18 +++++- .../messages/ui/MentionAutocomplete.tsx | 4 +- .../messages/ui/MessageAgentAddressPrefix.tsx | 4 +- .../messages/ui/NewMessageResultRow.tsx | 4 +- .../messages/ui/TypingIndicatorRow.tsx | 4 +- .../messages/ui/useAgentAddressLockPicker.ts | 6 +- .../ui/useMentionSendFlow.test-support.mjs | 3 + .../messages/ui/useMentionSendFlow.ts | 4 +- .../messages/ui/useNewMessageRecipients.ts | 4 +- .../use-feed-desktop-notifications.ts | 4 +- .../features/profile/lib/identity.test.mjs | 52 +++++++++++++++- desktop/src/features/profile/lib/identity.ts | 8 +-- .../features/profile/ui/UserProfilePanel.tsx | 6 +- .../profile/ui/UserProfilePanelUtils.ts | 8 +-- .../profile/ui/UserProfilePopover.tsx | 6 +- .../ui/useProfileInteractionActions.ts | 4 +- .../projects/ui/IssueAssigneesRow.tsx | 8 +-- .../projects/ui/ProjectDetailFeedPanels.tsx | 4 +- .../ui/ProjectPullRequestInlineComments.tsx | 4 +- .../projects/ui/ProjectPullRequestsPanel.tsx | 4 +- .../projects/ui/PullRequestReviewersRow.tsx | 8 +-- .../features/pulse/ui/AgentActivityCard.tsx | 4 +- desktop/src/features/pulse/ui/NoteCard.tsx | 6 +- desktop/src/features/pulse/ui/PulseView.tsx | 4 +- .../src/features/search/ui/TopbarSearch.tsx | 4 +- .../settings/ui/ModerationQueueCard.tsx | 19 +++--- .../sidebar/ui/ChannelActivityPopover.tsx | 4 +- .../workflows/ui/WorkflowAuthorPicker.tsx | 4 +- .../ui/workflowStepDescription.test.mjs | 61 +++++++++++++++++++ .../workflows/ui/workflowStepDescription.ts | 12 +++- .../ui/workflowTriggerDescription.test.mjs | 35 +++++++++++ .../ui/workflowTriggerDescription.ts | 8 ++- desktop/src/shared/lib/initials.test.mjs | 48 +++++++++++++++ desktop/src/shared/lib/initials.ts | 45 ++++++++++++++ .../src/shared/lib/mentionDisplay.test.mjs | 37 +++++++++-- desktop/src/shared/lib/mentionDisplay.ts | 33 ++++++++-- .../ui/markdown/useMessageLinkMetadata.ts | 4 +- .../shared/ui/markdownMentionDisplay.test.mjs | 4 +- desktop/tests/e2e/mention-recipients.spec.ts | 8 +-- desktop/tests/e2e/mentions.spec.ts | 9 ++- .../e2e/pubkey-display-screenshots.spec.ts | 9 +-- .../tests/e2e/workflow-local-controls.spec.ts | 13 ++-- 67 files changed, 540 insertions(+), 168 deletions(-) diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index 355c8e5d4f2..04ce9256a30 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -10,7 +10,7 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import type { Channel } from "@/shared/api/types"; @@ -72,7 +72,7 @@ export function useTrayMenu({ activityId: `${channelTurn.channelId}:${normalizePubkey(pubkey)}`, agentName: agentNames.get(normalizePubkey(pubkey)) ?? - `Agent ${truncatePubkey(pubkey)}`, + `Agent ${truncateNpub(pubkey)}`, channelId: channelTurn.channelId, channelName: channelNames.get(channelTurn.channelId) ?? "Unknown channel", diff --git a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx index 7db7c32bfa5..89588b57567 100644 --- a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx +++ b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx @@ -15,7 +15,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { UserSearchResult } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -25,7 +25,7 @@ export function formatShareRecipientName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/channels/lib/memberUtils.ts b/desktop/src/features/channels/lib/memberUtils.ts index d921457e000..10ad4b7ff0a 100644 --- a/desktop/src/features/channels/lib/memberUtils.ts +++ b/desktop/src/features/channels/lib/memberUtils.ts @@ -1,5 +1,5 @@ import type { ChannelMember } from "@/shared/api/types"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; export const roleOrder: Record = { owner: 0, @@ -17,7 +17,7 @@ export function formatMemberName( return "You"; } - return member.displayName ?? truncatePubkey(member.pubkey); + return member.displayName ?? truncateNpub(member.pubkey); } export function compareMembersByRole( diff --git a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx index 7edeb842c8b..9e7dda9c7fe 100644 --- a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx +++ b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx @@ -3,7 +3,7 @@ import { Bot } from "lucide-react"; import type { UserSearchResult } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; const MEMBER_ROW_INSET_DIVIDER_CLASS = @@ -13,7 +13,7 @@ export function formatAddCandidateName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -67,7 +67,7 @@ export function AddMemberSearchResultRow({ /> - {truncatePubkey(user.pubkey)} + {truncateNpub(user.pubkey)} {ownerLabel ? ( diff --git a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx index 90fb5bfaa9b..335e379ce8e 100644 --- a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx @@ -2,7 +2,7 @@ import { Search, UserPlus, X } from "lucide-react"; import * as React from "react"; import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery } from "@/features/profile/hooks"; @@ -19,7 +19,7 @@ function formatSearchUserName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -270,11 +270,11 @@ export function ChannelMemberInviteCard({

- {truncatePubkey(directInvitee.pubkey)} + {truncateNpub(directInvitee.pubkey)}

by public key @@ -370,7 +370,7 @@ export function ChannelMemberInviteCard({
{submissionErrors.map((error) => (

- {truncatePubkey(error.pubkey)}: {error.error} + {truncateNpub(error.pubkey)}: {error.error}

))}
diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 1b0b7687c1b..3c9afbfaeca 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -49,7 +49,7 @@ import { } from "@/shared/ui/dialog"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { MODAL_SEARCH_INPUT_CLASS, MODAL_SEARCH_SHELL_CLASS, @@ -641,7 +641,7 @@ export function MembersSidebar({ managedAgentRuntime={managedAgentRuntime} member={member} memberIsBot={memberIsBot} - memberAvatarLabel={member.displayName ?? truncatePubkey(member.pubkey)} + memberAvatarLabel={member.displayName ?? truncateNpub(member.pubkey)} memberLabel={formatMemberName(member, currentPubkey)} moderationState={moderationStateByPubkey.get( normalizePubkey(member.pubkey), @@ -886,7 +886,7 @@ export function MembersSidebar({
{inviteSubmissionErrors.map((error) => (

- {truncatePubkey(error.pubkey)}: {error.error} + {truncateNpub(error.pubkey)}: {error.error}

))}
diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 9f187388645..6cc3da1d9c2 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -27,7 +27,7 @@ import { MANAGED_AGENT_PAIR_ACTION_LABELS, type ManagedAgentPairAction, } from "@/features/agents/managedAgentRuntimeStatus"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import type { ChannelMember, ManagedAgent, @@ -195,7 +195,7 @@ export function MembersSidebarMemberCard({
- {truncatePubkey(member.pubkey)} + {truncateNpub(member.pubkey)} diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index e12c1bc7768..d5cd1824e0d 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -8,7 +8,7 @@ import type { RelayAgent, } from "@/shared/api/types"; import { usePanelReturnTarget } from "@/shared/hooks/usePanelReturnTarget"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { channelBotMemberPubkeySet, channelMemberPubkeySet, @@ -100,7 +100,7 @@ export function buildChannelAgentSessionCandidates({ byPubkey.set(key, { pubkey: member.pubkey, - name: member.displayName ?? truncatePubkey(member.pubkey), + name: member.displayName ?? truncateNpub(member.pubkey), status: "deployed", agentSource: "member-bot", canInterruptTurn: false, diff --git a/desktop/src/features/community-members/ui/AddMemberDialog.tsx b/desktop/src/features/community-members/ui/AddMemberDialog.tsx index 5464111752f..c2a26b5df3b 100644 --- a/desktop/src/features/community-members/ui/AddMemberDialog.tsx +++ b/desktop/src/features/community-members/ui/AddMemberDialog.tsx @@ -13,7 +13,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; import type { RelayMemberRole, UserSearchResult } from "@/shared/api/types"; import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -50,7 +50,7 @@ function formatSearchUserName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/community-members/ui/CommunityMembersCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersCard.tsx index fdc75f3d183..7693212711b 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersCard.tsx @@ -3,7 +3,7 @@ import { MoreHorizontal, Plus, Shield, ShieldCheck, User } from "lucide-react"; import { toast } from "sonner"; import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useChangeRelayMemberRoleMutation, @@ -101,7 +101,7 @@ function MemberRow({
- {displayName || truncatePubkey(member.pubkey)} + {displayName || truncateNpub(member.pubkey)} {isSelf ? ( diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index 29338ff5672..dad1f7ff6bc 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -19,7 +19,11 @@ import type { RelayMemberRole, UserProfileSummary, } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { + normalizePubkey, + truncateNpub, + UNAVAILABLE_KEY_LABEL, +} from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -65,7 +69,7 @@ function HoverMemberIdentity({ displayName: string; pubkey: string; }) { - const npub = npubFromPubkey(pubkey) ?? pubkey; + const npub = npubFromPubkey(pubkey) ?? UNAVAILABLE_KEY_LABEL; return ( - {truncatePubkey(npub)} + {truncateNpub(npub)} ); diff --git a/desktop/src/features/community-members/ui/ConfirmRemoveDialog.tsx b/desktop/src/features/community-members/ui/ConfirmRemoveDialog.tsx index 64735a5d567..087ef952fff 100644 --- a/desktop/src/features/community-members/ui/ConfirmRemoveDialog.tsx +++ b/desktop/src/features/community-members/ui/ConfirmRemoveDialog.tsx @@ -1,6 +1,6 @@ import { toast } from "sonner"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useRemoveRelayMemberMutation } from "@/features/community-members/hooks"; import type { RelayMember } from "@/shared/api/types"; @@ -25,7 +25,7 @@ export function ConfirmRemoveDialog({ onOpenChange: (open: boolean) => void; }) { const removeMutation = useRemoveRelayMemberMutation(); - const label = displayName || (member ? truncatePubkey(member.pubkey) : ""); + const label = displayName || (member ? truncateNpub(member.pubkey) : ""); function handleOpenChange(next: boolean) { if (!next) { diff --git a/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs b/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs index 7b34b901619..52d7a0a114d 100644 --- a/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs +++ b/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs @@ -250,6 +250,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts.ts"; import { joinAlertStorageKey } from "@/features/community-members/lib/joinAlerts.ts"; +import { truncateNpub } from "@/shared/lib/pubkey.ts"; import { relayClient } from "@/shared/api/relayClient.ts"; import { CommunitiesProvider } from "@/features/communities/useCommunities.tsx"; import { useCommunities } from "@/features/communities/useCommunities.tsx"; @@ -1715,7 +1716,7 @@ describe("useCommunityJoinAlerts — mounted subscription behaviour", () => { "a frame that predates the demotion must not re-open disclosure", ); assert.ok( - !notifications.some((entry) => entry.body?.includes(BOB.slice(0, 8))), + !notifications.some((entry) => entry.body?.includes(truncateNpub(BOB))), "the demoted viewer must never learn the new member's identity", ); assert.equal( @@ -1982,11 +1983,11 @@ describe("useCommunityJoinAlerts — mounted subscription behaviour", () => { "a community switch clears the latch: the feature recovers without a reload", ); assert.ok( - notifications.some((entry) => entry.body?.includes(BOB.slice(0, 8))), + notifications.some((entry) => entry.body?.includes(truncateNpub(BOB))), "the join suppressed by the latch is re-announced, not lost", ); assert.ok( - notifications.some((entry) => entry.body?.includes(CAROL.slice(0, 8))), + notifications.some((entry) => entry.body?.includes(truncateNpub(CAROL))), "and the new join lands too", ); diff --git a/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs b/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs index 65e2912ce70..e827635d145 100644 --- a/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs +++ b/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs @@ -86,6 +86,9 @@ async function setup(options = {}) { "@/shared/lib/pubkey": { normalizePubkey: (s) => s.toLowerCase(), truncatePubkey: (s) => s, + // Compact-identity seam stubbed alongside its sibling: this suite + // renders display names, never key-form labels. + truncateNpub: (s) => s, }, "@/features/channels/hooks": { useAddChannelMembersMutation: () => ({ diff --git a/desktop/src/features/forum/ui/useForumMentionPreparation.ts b/desktop/src/features/forum/ui/useForumMentionPreparation.ts index 09a7392a829..3289a5e8068 100644 --- a/desktop/src/features/forum/ui/useForumMentionPreparation.ts +++ b/desktop/src/features/forum/ui/useForumMentionPreparation.ts @@ -4,7 +4,7 @@ import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/chan import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; import type { ChannelType } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; type PendingInvite = { channelId: string; @@ -176,7 +176,7 @@ export function useForumMentionPreparation( isInvitePending: isInviting, names: (pending?.nonMemberPubkeys ?? []).map( (pubkey) => - mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey), + mentions.getMentionDisplayName(pubkey) ?? truncateNpub(pubkey), ), onDismiss: dismiss, onInvite: () => void invite(), diff --git a/desktop/src/features/home/ui/RecentNotesSection.tsx b/desktop/src/features/home/ui/RecentNotesSection.tsx index e0837b219df..3bff5c015a7 100644 --- a/desktop/src/features/home/ui/RecentNotesSection.tsx +++ b/desktop/src/features/home/ui/RecentNotesSection.tsx @@ -4,7 +4,7 @@ import type { UserNote } from "@/shared/api/socialTypes"; import type { UserProfileSummary } from "@/shared/api/types"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; type RecentNotesSectionProps = { notes: UserNote[]; @@ -52,8 +52,7 @@ export function RecentNotesSection({
{notes.slice(0, 5).map((note) => { const profile = profiles[note.pubkey.toLowerCase()]; - const displayName = - profile?.displayName ?? truncatePubkey(note.pubkey); + const displayName = profile?.displayName ?? truncateNpub(note.pubkey); const isAgent = agentPubkeys.has(note.pubkey); return ( diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index d5a0423cf7c..4c01ca68a15 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -33,7 +33,7 @@ import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog"; import type { HuddleAgentVoiceSettings } from "./AgentVoiceMenu"; import { MicControls, SpeakerControls } from "./MicControls"; import { HuddleParticipantsControl } from "./ParticipantList"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; // Mirrors HuddleState in src-tauri/src/huddle/mod.rs. type HuddleState = { @@ -98,7 +98,7 @@ function clampReactionName(name: string): string { } function fallbackNameForPubkey(pubkey?: string | null): string { - return pubkey ? `Participant ${truncatePubkey(pubkey)}` : "Someone"; + return pubkey ? `Participant ${truncateNpub(pubkey)}` : "Someone"; } function parseHuddleReactionEvent(event: RelayEvent) { diff --git a/desktop/src/features/huddle/components/ParticipantList.tsx b/desktop/src/features/huddle/components/ParticipantList.tsx index 04b6a7453bf..b7e7ffbb008 100644 --- a/desktop/src/features/huddle/components/ParticipantList.tsx +++ b/desktop/src/features/huddle/components/ParticipantList.tsx @@ -11,7 +11,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import type { VoiceRegistryEntry } from "@/features/settings/ui/voiceSettingsLogic"; import { invokeTauri } from "@/shared/api/tauri"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -113,7 +113,7 @@ function buildParticipantIdentities({ const displayName = profile?.displayName?.trim() || agent?.name?.trim() || - `${isAgent ? "Agent" : "Participant"} ${truncatePubkey(pubkey)}`; + `${isAgent ? "Agent" : "Participant"} ${truncateNpub(pubkey)}`; const speakerLevel = normalizedSpeakerLevels.get(normalizedPubkey) ?? (activeSpeakerSet.has(normalizedPubkey) ? 0.55 : 0); diff --git a/desktop/src/features/huddle/lib/huddleChannelName.ts b/desktop/src/features/huddle/lib/huddleChannelName.ts index c9f6dff2835..bd01c0d3e8a 100644 --- a/desktop/src/features/huddle/lib/huddleChannelName.ts +++ b/desktop/src/features/huddle/lib/huddleChannelName.ts @@ -1,5 +1,5 @@ import type { Channel, ChannelMember } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; type BuildHuddleChannelNameInput = { channel: Channel; @@ -33,7 +33,7 @@ function channelParticipantLabel( return firstName(fallbackName); } - return truncatePubkey(pubkey); + return truncateNpub(pubkey); } export function buildHuddleChannelName({ diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index a24da67f700..b35e8140481 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -45,7 +45,7 @@ import { formatTime } from "@/features/messages/lib/dateFormatters"; // Pure overlay helper lives in a sibling .mjs so node:test (no TS loader) // can exercise the exact same source the renderer uses. import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; const HEX_RE = /^[0-9a-f]+$/i; @@ -387,7 +387,7 @@ export function formatTimelineMessages( ? "You" : profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(actorPubkey); + truncateNpub(actorPubkey); existing.users.push({ pubkey: actorPubkey, displayName, diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 2ee46b01415..9c714015b24 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -5,7 +5,7 @@ import type { ChannelRole, UserSearchResult, } from "@/shared/api/types"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; export function formatSearchUserDisplayName(user: UserSearchResult) { return user.displayName?.trim() || user.nip05Handle?.trim() || null; @@ -54,7 +54,7 @@ export type MentionCandidate = { export function mentionCandidateLabel(candidate: MentionCandidate) { return ( candidate.displayName ?? - (candidate.pubkey ? truncatePubkey(candidate.pubkey) : "agent") + (candidate.pubkey ? truncateNpub(candidate.pubkey) : "agent") ); } diff --git a/desktop/src/features/messages/lib/mentionClipboard.test.mjs b/desktop/src/features/messages/lib/mentionClipboard.test.mjs index 39f16c96791..ac6f382db17 100644 --- a/desktop/src/features/messages/lib/mentionClipboard.test.mjs +++ b/desktop/src/features/messages/lib/mentionClipboard.test.mjs @@ -444,20 +444,39 @@ test("does not treat an uncapped label's ellipsis as a truncation", () => { test("whole compact mention labels are restored only for their declared exact key", () => { const key = `150b20bd${"a".repeat(52)}15dc`; const label = `Scout (${key}) 2`; - const compact = "Scout (150b20bd…15dc) 2"; - assert.equal(matchChipTextToLabel(compact, label, "@", key), "truncated"); + // The npub compact a chip renders today, and the hex compact a chip copied + // before keys displayed as npub still carries: a whole chip in either + // form re-binds; nothing else does. + const npubCompact = "Scout (npub1z59…zwkg) 2"; + const legacyCompact = "Scout (150b20bd…15dc) 2"; + assert.notEqual(npubCompact, legacyCompact); + + for (const compact of [npubCompact, legacyCompact]) { + assert.equal(matchChipTextToLabel(compact, label, "@", key), "truncated"); + assert.equal( + matchChipTextToLabel(`@${compact}`, label, "@", key), + "truncated", + ); + } + assert.equal(matchChipTextToLabel(npubCompact, label, "@"), "fragment"); + assert.equal( + matchChipTextToLabel(npubCompact, label, "@", "b".repeat(64)), + "fragment", + ); + assert.equal(matchChipTextToLabel(npubCompact, label, "#", key), "fragment"); + // A different key's compact — npub or legacy hex — is text this record + // never declared: tampered text never gains the identity's binding. assert.equal( - matchChipTextToLabel(`@${compact}`, label, "@", key), - "truncated", + matchChipTextToLabel("Scout (npub1m6k…zuz0) 2", label, "@", key), + "fragment", ); - assert.equal(matchChipTextToLabel(compact, label, "@"), "fragment"); assert.equal( - matchChipTextToLabel(compact, label, "@", "b".repeat(64)), + matchChipTextToLabel("Scout (deadbeef…beef) 2", label, "@", key), "fragment", ); - assert.equal(matchChipTextToLabel(compact, label, "#", key), "fragment"); + // A dropped collision suffix is a partial chip, not a tolerated form. assert.equal( - matchChipTextToLabel("Scout (150b20bd…15dc)", label, "@", key), + matchChipTextToLabel("Scout (npub1z59…zwkg)", label, "@", key), "fragment", ); assert.equal(matchChipTextToLabel("Scout", label, "@", key), "fragment"); diff --git a/desktop/src/features/messages/lib/mentionClipboard.ts b/desktop/src/features/messages/lib/mentionClipboard.ts index 8886bf8c29d..8b093610a49 100644 --- a/desktop/src/features/messages/lib/mentionClipboard.ts +++ b/desktop/src/features/messages/lib/mentionClipboard.ts @@ -1,4 +1,7 @@ -import { formatMentionDisplayLabel } from "@/shared/lib/mentionDisplay"; +import { + formatLegacyMentionDisplayLabel, + formatMentionDisplayLabel, +} from "@/shared/lib/mentionDisplay"; import { truncateInlineChipLabel } from "@/shared/ui/mentionChip"; import { getMentionOffsets } from "./hasMention"; @@ -96,6 +99,17 @@ export function matchChipTextToLabel( if (compact !== label && matches(canonicalMentionLabel(compact))) { return "truncated"; } + // A chip copied before keys displayed as npub carries the hex-truncated + // key in its text. Accept that prior *display* form — derived from the + // same label and pubkey this record declares, never from the pasted text + // itself — so an old whole-chip copy still re-binds instead of losing its + // identity. The two forms are disjoint (a hex truncation cannot contain + // the `n` an npub starts with), so this cannot reclassify any new chip. + const legacy = + sigil === "@" ? formatLegacyMentionDisplayLabel(label, pubkey) : label; + if (legacy !== label && matches(canonicalMentionLabel(legacy))) { + return "truncated"; + } return "fragment"; } diff --git a/desktop/src/features/messages/lib/mentionRanking.ts b/desktop/src/features/messages/lib/mentionRanking.ts index 3df5bba0b0a..deef2d3ebff 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -1,4 +1,4 @@ -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; export type MentionCandidateForRanking = { displayName: string | null; @@ -118,7 +118,7 @@ export function rankMentionCandidates( : ""; const label = candidate.displayName ?? - (candidate.pubkey ? truncatePubkey(candidate.pubkey) : "agent"); + (candidate.pubkey ? truncateNpub(candidate.pubkey) : "agent"); const groupRank = getMentionCandidateGroupRank( candidate, activePersonaIds, diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs index 3e0563ab96c..a42a3a9aecb 100644 --- a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs +++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs @@ -272,10 +272,22 @@ test("compact mention paste expands only complete bound labels", () => { const html = (text) => `${text}`; + // Today's chip text renders the key as npub… + assert.equal( + normalizeMentionClipboardContent(html("Scout (npub1z59…zwkg) 2")).text, + `@${label}`, + ); + // …and a whole chip copied before that switch still carries the hex + // compact, which must keep expanding to the exact declared identity. assert.equal( normalizeMentionClipboardContent(html("Scout (150b20bd…15dc) 2")).text, `@${label}`, ); + // A dropped collision suffix — in either form — is a partial chip. + assert.equal( + normalizeMentionClipboardContent(html("Scout (npub1z59…zwkg)")).text, + "Scout (npub1z59…zwkg)", + ); assert.equal( normalizeMentionClipboardContent(html("Scout (150b20bd…15dc)")).text, "Scout (150b20bd…15dc)", diff --git a/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs b/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs index 4f8c05842aa..bdc9801c5ac 100644 --- a/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs +++ b/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs @@ -193,7 +193,7 @@ test("copy expands compact key text but preserves the exact label and identity", const flavors = copyRenderedBody( `` + - 'Scout (7c7c7c7c…7c7c) 2', + 'Scout (npub1037…08vj) 2', ); assert.ok(flavors); assert.ok(flavors.html.includes(`@${label}`)); @@ -202,3 +202,19 @@ test("copy expands compact key text but preserves the exact label and identity", ); assert.ok(!flavors.html.includes("…")); }); + +test("copy expands a legacy hex-compact chip to its exact identity", () => { + // A chip rendered before keys displayed as npub carries the hex compact in + // its text; copying it whole must still expand to the declared identity. + const label = `Scout (${JOHN_SMITH_PUBKEY}) 2`; + const flavors = copyRenderedBody( + `` + + 'Scout (7c7c7c7c…7c7c) 2', + ); + assert.ok(flavors); + assert.ok(flavors.html.includes(`@${label}`)); + assert.ok( + flavors.html.includes(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`), + ); +}); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index a380ac83a09..65ae331d594 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -15,7 +15,7 @@ import { Switch } from "@/shared/ui/switch"; import { Toggle } from "@/shared/ui/toggle"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { safeNpub } from "@/shared/lib/nostrUtils"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { getPlatformKeysById } from "@/shared/lib/keyboard-shortcuts"; export type MentionSuggestion = { @@ -387,7 +387,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ data-testid="mention-collision-npub" title={collisionNpub} > - {truncatePubkey(collisionNpub)} + {truncateNpub(collisionNpub)} ) : null} diff --git a/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx b/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx index e3ab0c6076a..644a8dc80d3 100644 --- a/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx +++ b/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx @@ -5,7 +5,7 @@ import { getVisibleAgentAddressPubkeys } from "../lib/getVisibleAgentAddressPubk import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { InlineChip } from "@/shared/ui/InlineChip"; /** Resolve all literal competitors before hiding tag-backed address chips. */ @@ -54,7 +54,7 @@ export function MessageAgentAddressPrefix({ const label = profile?.displayName?.trim() || profile?.name?.trim() || - truncatePubkey(pubkey); + truncateNpub(pubkey); return ( {/* biome-ignore lint/a11y/useValidAriaRole: UserProfilePopover uses role for agent classification, not as an ARIA attribute. */} diff --git a/desktop/src/features/messages/ui/NewMessageResultRow.tsx b/desktop/src/features/messages/ui/NewMessageResultRow.tsx index 9625fb7882e..ea07a0607a1 100644 --- a/desktop/src/features/messages/ui/NewMessageResultRow.tsx +++ b/desktop/src/features/messages/ui/NewMessageResultRow.tsx @@ -6,7 +6,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { formatRecipientName } from "./useNewMessageRecipients"; @@ -28,7 +28,7 @@ function HoverRecipientIdentity({ displayName: string; pubkey: string; }) { - const identityLabel = truncatePubkey(pubkey); + const identityLabel = truncateNpub(pubkey); return ( {typingPubkeys.map((pubkey, index) => { const profile = profiles?.[pubkey.toLowerCase()]; - const label = labels[index] ?? truncatePubkey(pubkey); + const label = labels[index] ?? truncateNpub(pubkey); return (
key.toLowerCase(), truncatePubkey: (key) => key, + // Compact-identity seam stubbed alongside its sibling: these suites + // render display names, never key-form labels. + truncateNpub: (key) => key, }, "@/shared/lib/customEmojiTags": { buildCustomEmojiTags: () => [] }, "./useMentionSendFlow.helpers": helpers, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 9847193101b..299c0144ab4 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -28,7 +28,7 @@ import { useDetachedAgentStart } from "./useDetachedAgentStart"; import { useEnsureAgentMentionsReady } from "./useEnsureAgentMentionsReady"; import { invokeTauri } from "@/shared/api/tauri"; import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { dedupeQueuedAgentWakes, @@ -941,7 +941,7 @@ export function useMentionSendFlow({ if (!pendingNonMemberSend) return []; return pendingNonMemberSend.nonMemberPubkeys.map( (pubkey) => - mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey), + mentions.getMentionDisplayName(pubkey) ?? truncateNpub(pubkey), ); }, [mentions.getMentionDisplayName, pendingNonMemberSend]); const invitation = useNonMemberInvite({ diff --git a/desktop/src/features/messages/ui/useNewMessageRecipients.ts b/desktop/src/features/messages/ui/useNewMessageRecipients.ts index ffc45c6c9e2..c1b821da62b 100644 --- a/desktop/src/features/messages/ui/useNewMessageRecipients.ts +++ b/desktop/src/features/messages/ui/useNewMessageRecipients.ts @@ -20,7 +20,7 @@ import { import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ManagedAgent, UserSearchResult } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; /** Maximum recipients (excluding the current user) a DM can address. */ export const NEW_MESSAGE_RECIPIENT_LIMIT = 8; @@ -37,7 +37,7 @@ export function formatRecipientName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index 4a0865437ed..e4594547eed 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -1,6 +1,6 @@ import * as React from "react"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { resolveUserLabel, type UserProfileLookup, @@ -204,7 +204,7 @@ export function useFeedDesktopNotifications( : undefined; // Only use real display names, not truncated pubkey fallbacks. const senderName = - resolvedLabel && resolvedLabel !== truncatePubkey(item.pubkey) + resolvedLabel && resolvedLabel !== truncateNpub(item.pubkey) ? resolvedLabel : undefined; void deliverFeedNotification(item, senderName); diff --git a/desktop/src/features/profile/lib/identity.test.mjs b/desktop/src/features/profile/lib/identity.test.mjs index da0259a66fc..3358de5888a 100644 --- a/desktop/src/features/profile/lib/identity.test.mjs +++ b/desktop/src/features/profile/lib/identity.test.mjs @@ -1,11 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { formatOwnerLabel, profileLookupsEqual } from "./identity.ts"; +import { + formatOwnerLabel, + profileLookupsEqual, + resolveUserLabel, +} from "./identity.ts"; const OWNER_PUBKEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +// npubEncode(OWNER_PUBKEY), pinned so a fallback regression cannot pass by +// re-deriving the expectation from the code under test. +const OWNER_NPUB_COMPACT = "npub1hwa…04hu"; + const summary = (over = {}) => ({ displayName: "Ada", avatarUrl: "https://x/a.png", @@ -32,6 +40,48 @@ test("formatOwnerLabel returns null when verified ownership is absent", () => { assert.equal(formatOwnerLabel(null, OWNER_PUBKEY, {}), null); }); +test("resolveUserLabel falls back to the key’s compact npub, never raw hex", () => { + // No profile, no fallback name: the last resort is the npub compact. + assert.equal( + resolveUserLabel({ pubkey: OWNER_PUBKEY, profiles: {} }), + OWNER_NPUB_COMPACT, + ); + // A provided fallback name still wins over the key form. + assert.equal( + resolveUserLabel({ + pubkey: OWNER_PUBKEY, + profiles: {}, + fallbackName: "legacy relay agent", + }), + "legacy relay agent", + ); + // A resolved display name wins over everything. + assert.equal( + resolveUserLabel({ + pubkey: OWNER_PUBKEY, + profiles: { [OWNER_PUBKEY]: summary({ displayName: "baxen" }) }, + }), + "baxen", + ); +}); + +test("formatOwnerLabel falls back to the owner’s compact npub", () => { + assert.equal( + formatOwnerLabel(OWNER_PUBKEY, "c".repeat(64), {}), + OWNER_NPUB_COMPACT, + ); + // A NIP-05 handle still wins over the key form. + assert.equal( + formatOwnerLabel(OWNER_PUBKEY, "c".repeat(64), { + [OWNER_PUBKEY]: summary({ + nip05Handle: "baxen@relay", + displayName: null, + }), + }), + "baxen@relay", + ); +}); + test("profileLookupsEqual: same reference is equal", () => { const a = { p1: summary() }; assert.equal(profileLookupsEqual(a, a), true); diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index d2e0a4fdd38..a0d32faaaa4 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -1,9 +1,9 @@ import type { Profile, UserProfileSummary } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; export type UserProfileLookup = Record; -export { truncatePubkey }; +export { truncateNpub }; /** * Deep-equal two profile lookups by value. Used to stabilise the merged @@ -128,7 +128,7 @@ export function resolveUserLabel(input: { return safeFallback; } - return truncatePubkey(pubkey); + return truncateNpub(pubkey); } /** @@ -188,6 +188,6 @@ export function formatOwnerLabel( return ( owner?.displayName?.trim() || owner?.nip05Handle?.trim() || - truncatePubkey(ownerPubkey) + truncateNpub(ownerPubkey) ); } diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index d060deb5323..bad18369fe3 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -77,7 +77,7 @@ import { resolveAgentInstruction, resolvePanelProfile, resolveProfileDisplayName, - truncatePubkey, + truncateNpub, type UserProfilePanelProps, useRetainedPersona, } from "@/features/profile/ui/UserProfilePanelUtils"; @@ -675,7 +675,7 @@ export function UserProfilePanel({ return ( ownerProfile?.nip05Handle?.trim() || ownerProfile?.displayName?.trim() || - truncatePubkey(ownerPubkey) + truncateNpub(ownerPubkey) ); } @@ -687,7 +687,7 @@ export function UserProfilePanel({ return ( currentProfile?.nip05Handle?.trim() || currentProfile?.displayName?.trim() || - truncatePubkey(currentPubkey) + truncateNpub(currentPubkey) ); }, [ currentProfileQuery.data, diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index be1a57c112e..e7d5a4d4ef8 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -8,9 +8,9 @@ import type { RelayAgent, UpdateManagedAgentInput, } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; -export { truncatePubkey }; +export { truncateNpub }; export type ProfileChannelLink = { id: string; @@ -237,7 +237,7 @@ export function resolveProfileDisplayName({ return ( profile?.displayName ?? persona?.displayName ?? - (pubkey ? truncatePubkey(pubkey) : "Agent") + (pubkey ? truncateNpub(pubkey) : "Agent") ); } @@ -252,7 +252,7 @@ export function resolveOwnerHandle( return ( profile?.nip05Handle?.trim() || profile?.displayName?.trim() || - truncatePubkey(currentPubkey) + truncateNpub(currentPubkey) ); } diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 3fab7b8102e..c36b59e3af6 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -30,7 +30,7 @@ import { ProfileAvatarWithStatus } from "@/features/profile/ui/ProfileAvatarWith import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { useProfileInteractionActions } from "@/features/profile/ui/useProfileInteractionActions"; import { @@ -108,7 +108,7 @@ function HoverPubkeyName({ - {truncatePubkey(pubkey)} + {truncateNpub(pubkey)} ); @@ -297,7 +297,7 @@ function UserProfilePopoverBody({ relayAgentsQuery.isPending || managedAgentsQuery.isPending || usersBatchQuery.isPending); - const displayName = profile?.displayName ?? truncatePubkey(pubkey); + const displayName = profile?.displayName ?? truncateNpub(pubkey); // Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent // runs elsewhere holds no local seckey, so key custody (`isOwner`) alone // wrongly hides the affordance from them — and gating on bot-ness alone shows diff --git a/desktop/src/features/profile/ui/useProfileInteractionActions.ts b/desktop/src/features/profile/ui/useProfileInteractionActions.ts index f5ee47a7702..1993188f319 100644 --- a/desktop/src/features/profile/ui/useProfileInteractionActions.ts +++ b/desktop/src/features/profile/ui/useProfileInteractionActions.ts @@ -21,7 +21,7 @@ import { useIdentityQuery } from "@/shared/api/hooks"; import { sendChannelMessage } from "@/shared/api/tauri"; import type { Channel, RelayEvent } from "@/shared/api/types"; import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; export type ProfileInteractionAction = "huddle" | "message" | "wave"; @@ -207,7 +207,7 @@ export function useProfileInteractionActions({ const senderName = selfProfileQuery.data?.displayName?.trim() || identity.displayName.trim() || - truncatePubkey(identity.pubkey); + truncateNpub(identity.pubkey); const content = buildWaveMessageContent(senderName); const queryKey = channelMessagesKey(dm.id); diff --git a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx index 54073bac5dd..71855770f27 100644 --- a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx +++ b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx @@ -13,7 +13,7 @@ import { useUserSearchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -37,7 +37,7 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(pubkey) + truncateNpub(pubkey) ); } @@ -45,7 +45,7 @@ function assigneeSearchLabel(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -383,7 +383,7 @@ export function IssueAssigneesRow({ {candidate.isAgent ? "Agent · " : ""} - {truncatePubkey(candidate.pubkey)} + {truncateNpub(candidate.pubkey)} diff --git a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx index d960d3f09fc..bdb81011b3a 100644 --- a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx @@ -16,7 +16,7 @@ import { selectionItemFromCommit } from "@/features/projects/lib/projectSelectio import { commitShareLink } from "@/features/projects/lib/projectShareLinks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectRepoCommit } from "@/shared/api/types"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; import { resolveUserLabel, @@ -129,7 +129,7 @@ export function ContributorsPanel({ isAgent, label: profile ? resolveUserLabel({ profiles, pubkey }) - : truncatePubkey(pubkey), + : truncateNpub(pubkey), profileLinked: true, pubkey, reviewCount: signedCounts.reviews, diff --git a/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx b/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx index 281aefe71be..fc64ffc6324 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx @@ -7,7 +7,7 @@ import type { } from "@/features/projects/projectPullRequests.mjs"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { ProjectRichContent } from "./ProjectRichContent"; function commentAuthor( @@ -18,7 +18,7 @@ function commentAuthor( return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(pubkey) + truncateNpub(pubkey) ); } diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index b130bf552c5..9fe09f50cd4 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -33,7 +33,7 @@ import { canReviewProjectPullRequest } from "@/features/projects/pullRequestRevi import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ChannelMember } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { ProjectFeedRow, ProjectFeedRowCluster, @@ -70,7 +70,7 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(pubkey) + truncateNpub(pubkey) ); } diff --git a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx index fa5f205fabb..8b824f917d9 100644 --- a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx +++ b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx @@ -19,7 +19,7 @@ import { useUserSearchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -44,7 +44,7 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(pubkey) + truncateNpub(pubkey) ); } @@ -52,7 +52,7 @@ function reviewerSearchLabel(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -270,7 +270,7 @@ export function PullRequestReviewersRow({ {candidate.isAgent ? "Agent · " : ""} - {truncatePubkey(candidate.pubkey)} + {truncateNpub(candidate.pubkey)} diff --git a/desktop/src/features/pulse/ui/AgentActivityCard.tsx b/desktop/src/features/pulse/ui/AgentActivityCard.tsx index 30e8601912a..310820363e2 100644 --- a/desktop/src/features/pulse/ui/AgentActivityCard.tsx +++ b/desktop/src/features/pulse/ui/AgentActivityCard.tsx @@ -6,7 +6,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import type { UserProfileSummary } from "@/shared/api/types"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; type AgentActivityCardProps = { group: AgentNoteGroup; @@ -51,7 +51,7 @@ export function AgentActivityCard({ agentStatus, }: AgentActivityCardProps) { const [expanded, setExpanded] = React.useState(false); - const displayName = profile?.displayName ?? truncatePubkey(group.pubkey); + const displayName = profile?.displayName ?? truncateNpub(group.pubkey); const avatarUrl = profile?.avatarUrl ?? null; const isSingleNote = group.notes.length === 1; diff --git a/desktop/src/features/pulse/ui/NoteCard.tsx b/desktop/src/features/pulse/ui/NoteCard.tsx index 96f25a6ede4..7ce04ce3dba 100644 --- a/desktop/src/features/pulse/ui/NoteCard.tsx +++ b/desktop/src/features/pulse/ui/NoteCard.tsx @@ -18,7 +18,7 @@ import { AnimatedCount } from "@/shared/ui/AnimatedCount"; import { Markdown } from "@/shared/ui/markdown"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; export type NoteCardActions = { reply?: ( @@ -67,7 +67,7 @@ function ReplyParentContext({ const parentDisplayName = parentNote ? (cachedProfile?.displayName ?? fetchedProfile?.displayName ?? - truncatePubkey(parentNote.pubkey)) + truncateNpub(parentNote.pubkey)) : null; const parentAvatarUrl = cachedProfile?.avatarUrl ?? fetchedProfile?.avatarUrl ?? null; @@ -149,7 +149,7 @@ export function NoteCard({ members = [], actions, }: NoteCardProps) { - const displayName = profile?.displayName ?? truncatePubkey(note.pubkey); + const displayName = profile?.displayName ?? truncateNpub(note.pubkey); const avatarUrl = profile?.avatarUrl ?? null; const [isReplyComposerOpen, setIsReplyComposerOpen] = React.useState(false); const actionButtonClass = diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 2b595ed2ccb..01cd20d6225 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -30,7 +30,7 @@ import { Input } from "@/shared/ui/input"; import { Skeleton } from "@/shared/ui/skeleton"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; export type PulseTab = | "search" @@ -225,7 +225,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) { : null; const currentDisplayName = currentProfile?.displayName ?? - (currentPubkey ? truncatePubkey(currentPubkey) : "You"); + (currentPubkey ? truncateNpub(currentPubkey) : "You"); const pulseMentionMembers = React.useMemo(() => { const members: ChannelMember[] = []; diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 68e879a3033..5a032c780a4 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -20,7 +20,7 @@ import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchTex import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; import { @@ -134,7 +134,7 @@ function getUserDisplayName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/settings/ui/ModerationQueueCard.tsx b/desktop/src/features/settings/ui/ModerationQueueCard.tsx index 58dbe87ab79..9bb5962585a 100644 --- a/desktop/src/features/settings/ui/ModerationQueueCard.tsx +++ b/desktop/src/features/settings/ui/ModerationQueueCard.tsx @@ -34,7 +34,7 @@ import { type SeverityTier, } from "@/features/settings/lib/moderationQueue"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub, truncatePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -195,14 +195,15 @@ const SEVERITY_BADGE: Record = { }; function targetLabel(group: ModerationQueueGroup): string { - const short = truncatePubkey(group.target); switch (group.targetKind) { case "event": - return `Message ${short}`; + // An event id is not a pubkey identity: keep the generic hex form. + return `Message ${truncatePubkey(group.target)}`; case "pubkey": - return `Member ${short}`; + return `Member ${truncateNpub(group.target)}`; case "blob": - return `Attachment ${short}`; + // Blob ids are not pubkey identities either. + return `Attachment ${truncatePubkey(group.target)}`; } } @@ -213,7 +214,7 @@ function ReporterLine({ report: ModerationReport; displayName?: string | null; }) { - const who = displayName?.trim() || truncatePubkey(report.reporterPubkey); + const who = displayName?.trim() || truncateNpub(report.reporterPubkey); return (
@@ -476,9 +477,11 @@ function AuditRow({ action: ModerationAction; actorName?: string | null; }) { - const who = actorName?.trim() || truncatePubkey(action.actorPubkey); + const who = actorName?.trim() || truncateNpub(action.actorPubkey); + // Actor and member targets are pubkey identities; a targeted event keeps + // the generic hex truncation for its event id. const targetShort = action.targetPubkey - ? truncatePubkey(action.targetPubkey) + ? truncateNpub(action.targetPubkey) : action.targetEventId ? truncatePubkey(action.targetEventId) : null; diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index 34fdba0bdfe..f411ef2b72f 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -13,7 +13,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { Markdown } from "@/shared/ui/markdown"; import { @@ -215,7 +215,7 @@ function WorkingAgentRows({ const name = profile?.displayName?.trim() || alignedAgentNames?.[index] || - `Agent ${truncatePubkey(pubkey)}`; + `Agent ${truncateNpub(pubkey)}`; return ( {label} - {truncatePubkey(candidate.pubkey)} + {truncateNpub(candidate.pubkey)} { ); }); +test("key destinations render as the compact npub; roles and templates stay plain", () => { + const hexKey = "deadbeef".repeat(8); + // npubEncode(hexKey), pinned so a formatter regression cannot pass by + // re-deriving the expectation from the code under test. + const npubKey = + "npub1m6kmam774klwlh4dhmhaatd7al02m0h0m6kmam774klwlh4dhmhslezuz0"; + const brokenNpub = + "npub1m6kmam774klwlh4dhmhaatd7al02m0h0m6kmam774klwlh4dhmhslezuzy"; + + assert.equal( + workflowStepDescription({ + id: "dm", + action: "send_dm", + text: "Build finished", + to: hexKey, + }), + "“Build finished” to npub1m6k…zuz0", + ); + // npub-typed form values (normalized on load) compact identically. + assert.equal( + workflowStepDescription({ + id: "dm", + action: "send_dm", + text: "Build finished", + to: npubKey, + }), + "“Build finished” to npub1m6k…zuz0", + ); + // Hex casing is not identity: uppercase still renders the same key. + assert.equal( + workflowStepDescription({ + id: "approval", + action: "request_approval", + message: "Ship the release?", + from: hexKey.toUpperCase(), + }), + "“Ship the release?” from npub1m6k…zuz0", + ); + // Freeform roles and templates stay ordinary compacted text. + assert.equal( + workflowStepDescription({ + id: "dm", + action: "send_dm", + text: "Build finished", + to: "team-on-call", + }), + "“Build finished” to team-on-call", + ); + // An npub-shaped string with a broken checksum renders the neutral label, + // never the raw text as if it were a role. + assert.equal( + workflowStepDescription({ + id: "dm", + action: "send_dm", + text: "Build finished", + to: brokenNpub, + }), + "“Build finished” to Unavailable", + ); +}); + test("can omit a custom step name when composing an index label", () => { assert.equal( workflowStepDescription( diff --git a/desktop/src/features/workflows/ui/workflowStepDescription.ts b/desktop/src/features/workflows/ui/workflowStepDescription.ts index 136ce026111..40697fe352f 100644 --- a/desktop/src/features/workflows/ui/workflowStepDescription.ts +++ b/desktop/src/features/workflows/ui/workflowStepDescription.ts @@ -1,4 +1,4 @@ -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { formatDurationSecondsVerbose, parseDurationSeconds, @@ -20,11 +20,17 @@ function quoted(value: string | undefined): string | null { return normalized ? `“${normalized}”` : null; } +// DM/approver references are pubkeys — 64-char hex in any case, or a +// canonical lowercase npub — or freeform role/template text; only a key +// identity renders as a key (an npub-shaped string that fails the shared +// helper's checksum renders the neutral Unavailable, never raw text). +const KEY_REFERENCE = /^(?:[0-9a-f]{64}|npub1[0-9a-z]+)$/i; + function destination(value: string | undefined): string | null { const normalized = value?.trim(); if (!normalized) return null; - return /^[0-9a-f]{64}$/i.test(normalized) - ? truncatePubkey(normalized) + return KEY_REFERENCE.test(normalized) + ? truncateNpub(normalized) : compact(normalized); } diff --git a/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs b/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs index 1f2d03b2382..afa9da0ade6 100644 --- a/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs +++ b/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs @@ -172,6 +172,41 @@ test("describes selected trigger conditions on the workflow canvas", () => { ); }); +test("an unresolved author renders the compact npub while a referenced message keeps its event-id hex", () => { + const author = "deadbeef".repeat(8); + assert.equal( + workflowTriggerDescription({ + on: "message_posted", + filter: `trigger_author == "${author}"`, + }), + "Message posted by npub1m6k…zuz0", + ); + assert.equal( + workflowTriggerDescription({ + on: "message_posted", + filter: `trigger_author != "${author}"`, + }), + "Message posted by anyone except npub1m6k…zuz0", + ); + // The referenced message is an event id, not a pubkey identity: the + // generic hex truncation stays. + assert.equal( + workflowTriggerDescription({ + on: "reaction_added", + filter: `trigger_message_id == "${"b".repeat(64)}"`, + }), + "Reaction added to bbbbbbbb…bbbb", + ); + // Both references in one trigger keep their own form. + assert.equal( + workflowTriggerDescription({ + on: "reaction_added", + filter: `trigger_emoji == "👾" && trigger_author == "${author}" && trigger_message_id == "${"b".repeat(64)}"`, + }), + "👾 reaction added by npub1m6k…zuz0 to bbbbbbbb…bbbb", + ); +}); + test("compacts only an included emoji already rendered as the node icon", () => { assert.equal( compactWorkflowTriggerDescription("😍 reaction added", "😍"), diff --git a/desktop/src/features/workflows/ui/workflowTriggerDescription.ts b/desktop/src/features/workflows/ui/workflowTriggerDescription.ts index 51bba6c0102..a4927444974 100644 --- a/desktop/src/features/workflows/ui/workflowTriggerDescription.ts +++ b/desktop/src/features/workflows/ui/workflowTriggerDescription.ts @@ -1,4 +1,4 @@ -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub, truncatePubkey } from "@/shared/lib/pubkey"; import { parseConditionExpressions } from "./workflowConditionExpression"; import { TRIGGER_LABELS } from "./workflowFormTypes"; import type { ParsedConditionExpression } from "./workflowConditionExpression"; @@ -19,7 +19,9 @@ function authorReference( authorLoading?: boolean, ): string { if (authorLoading) return TRIGGER_AUTHOR_LOADING_LABEL; - return authorLabel ?? truncatePubkey(condition.value); + // The trigger author is a pubkey identity; the unresolved fallback renders + // the npub compact, never raw hex. + return authorLabel ?? truncateNpub(condition.value); } function quotedValue(value: string): string { @@ -35,6 +37,8 @@ function messageReference( messageLoading?: boolean, ): string { if (messageLoading) return TRIGGER_MESSAGE_LOADING_LABEL; + // The referenced message is an event id, not a pubkey identity: it keeps + // the generic hex truncation. return messageLabel ? quotedValue(messageLabel) : truncatePubkey(condition.value); diff --git a/desktop/src/shared/lib/initials.test.mjs b/desktop/src/shared/lib/initials.test.mjs index 19e9f8dbcc8..2bbbcca1dbd 100644 --- a/desktop/src/shared/lib/initials.test.mjs +++ b/desktop/src/shared/lib/initials.test.mjs @@ -19,4 +19,52 @@ describe("getInitials", () => { it("returns empty for a symbol-only name", () => { assert.equal(getInitials("()"), ""); }); + + it("derives key-label initials from the compact key’s visible tail, not the npub prefix", () => { + // Compact npub labels every start with npub1: the tail is the only + // fragment that distinguishes one key-identified identity from another. + assert.equal(getInitials("npub1z59…zwkg"), "ZW"); + assert.equal(getInitials("npub1m6k…zuz0"), "ZU"); + assert.equal(getInitials("npub1etl…wnmp"), "WN"); + }); + + it("derives full-npub initials from the same tail fragment", () => { + assert.equal( + getInitials( + "npub1z59jp0d24242424242424242424242424242424242424242zhwqnlzwkg", + ), + "ZW", + ); + }); + + it("leaves authored names that merely resemble npubs on the name path", () => { + // Not a key-shaped label: wrong lengths, separators, alphabet, or casing + // must keep the ordinary name derivation so an authored name is never + // re-derived as a key just for resembling one. + assert.equal(getInitials("Npub1 Person"), "NP"); + assert.equal(getInitials("npub1cool handle"), "NH"); + // Compact-label shape with a missing data or tail character. + assert.equal(getInitials("npub1ab…wxy"), "NW"); + assert.equal(getInitials("npub1…zwkg"), "NZ"); + // Compact-label shape over letters outside the bech32 alphabet. + assert.equal(getInitials("npub1bio…biob"), "NB"); + // Full-npub length with a non-bech32 character (uppercase tail). + assert.equal( + getInitials( + "npub1z59jp0d24242424242424242424242424242424242424242zhwqnlzwkG", + ), + "N", + ); + }); + + it("requires a checksum-valid npub before deriving key-tail initials", () => { + // Same length and alphabet as a real npub, but the checksum does not + // decode: an authored lookalike must stay on the name path. + assert.equal( + getInitials( + "npub1z59jp0d24242424242424242424242424242424242424242zhwqnlzwkq", + ), + "N", + ); + }); }); diff --git a/desktop/src/shared/lib/initials.ts b/desktop/src/shared/lib/initials.ts index 23dbf2ff93b..da5833086c0 100644 --- a/desktop/src/shared/lib/initials.ts +++ b/desktop/src/shared/lib/initials.ts @@ -1,5 +1,50 @@ +import { decode } from "nostr-tools/nip19"; + +/** + * Key-form labels carry no name to abbreviate: their head is the constant + * `npub1` prefix, so name-derived initials would collapse every key-identified + * identity onto the same leading letter. A key's distinguishing fragment is + * its tail — the part a compact key actually shows — so derive initials from + * there and keep key-fallback avatars visually distinct. + * + * Detection is deliberately narrow so authored names never lose their name + * initials for merely resembling a key: a full label counts as a key only + * when it decodes as a checksum-valid npub of an identity-length key, and a + * compact label must match the exact `npub` + 4 + `…` + 4 truncation shape + * over the bech32 alphabet (the form `truncateNpub` emits). A display name + * that is a checksum-valid npub is indistinguishable from a real key by + * shape alone, and taking key-tail initials there is the safe side of that + * boundary. + */ +const BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const COMPACT_NPUB_KEY_LABEL = new RegExp( + `^npub1[${BECH32_ALPHABET}]{3}…[${BECH32_ALPHABET}]{4}$`, +); +const HEX_64_REGEX = /^[0-9a-f]{64}$/; + +function isFullNpubLabel(label: string): boolean { + try { + const decoded = decode(label); + return decoded.type === "npub" && HEX_64_REGEX.test(decoded.data); + } catch { + return false; + } +} + +function keyTailInitials(name: string): string | null { + const trimmed = name.trim(); + if (!COMPACT_NPUB_KEY_LABEL.test(trimmed) && !isFullNpubLabel(trimmed)) { + return null; + } + return trimmed.slice(-4, -2).toUpperCase(); +} + /** Derive up to two uppercase initials from a display name. */ export function getInitials(name: string): string { + const keyInitials = keyTailInitials(name); + if (keyInitials !== null) { + return keyInitials; + } return name .replace(/[^\p{L}\p{N}\s]/gu, " ") .trim() diff --git a/desktop/src/shared/lib/mentionDisplay.test.mjs b/desktop/src/shared/lib/mentionDisplay.test.mjs index 20e0f17eb0b..7365b853d9a 100644 --- a/desktop/src/shared/lib/mentionDisplay.test.mjs +++ b/desktop/src/shared/lib/mentionDisplay.test.mjs @@ -1,21 +1,27 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { formatMentionDisplayLabel } from "./mentionDisplay.ts"; -import { truncatePubkey } from "./pubkey.ts"; +import { + formatLegacyMentionDisplayLabel, + formatMentionDisplayLabel, +} from "./mentionDisplay.ts"; +import { truncateNpub, truncatePubkey } from "./pubkey.ts"; const KEY = `150b20bd${"a".repeat(52)}15dc`; +// npubEncode(KEY), pinned so a formatter regression cannot pass by +// re-deriving the expectation from the code under test. +const KEY_NPUB_COMPACT = "npub1z59…zwkg"; test("compact mention display uses the member-list formatter and keeps collision suffixes", () => { for (const suffix of ["", " 2", " 10"]) { assert.equal( formatMentionDisplayLabel(`Bad Janet (${KEY})${suffix}`, KEY), - `Bad Janet (${truncatePubkey(KEY)})${suffix}`, + `Bad Janet (${truncateNpub(KEY)})${suffix}`, ); } - assert.equal(formatMentionDisplayLabel(KEY, KEY), truncatePubkey(KEY)); + assert.equal(formatMentionDisplayLabel(KEY, KEY), KEY_NPUB_COMPACT); assert.equal( formatMentionDisplayLabel(KEY.toUpperCase(), KEY), - truncatePubkey(KEY.toUpperCase()), + KEY_NPUB_COMPACT, ); }); @@ -32,10 +38,29 @@ test("display leaves unbound, mismatched, malformed and ordinary labels literal" assert.equal(formatMentionDisplayLabel(label, key), label); }); +test("legacy display keeps the retired hex compaction byte-exact for clipboard validation", () => { + for (const suffix of ["", " 2", " 10"]) { + assert.equal( + formatLegacyMentionDisplayLabel(`Bad Janet (${KEY})${suffix}`, KEY), + `Bad Janet (${truncatePubkey(KEY)})${suffix}`, + ); + } + assert.equal(formatLegacyMentionDisplayLabel(KEY, KEY), truncatePubkey(KEY)); + // The two display forms are distinct strings — the disjointness the + // clipboard classifier relies on when it tolerates only these two forms. + assert.notEqual( + formatMentionDisplayLabel(`Bad Janet (${KEY})`, KEY), + formatLegacyMentionDisplayLabel(`Bad Janet (${KEY})`, KEY), + ); +}); + test("matching compact keys do not become identity keys", () => { const other = KEY.replace("aaaa", "bbbb"); assert.notEqual(KEY, other); - assert.equal( + // The two keys collide under the old hex first8…last4 truncation yet + // compact to distinguishable npubs: npub display keeps them apart. + assert.equal(truncatePubkey(KEY), truncatePubkey(other)); + assert.notEqual( formatMentionDisplayLabel(`Scout (${KEY})`, KEY), formatMentionDisplayLabel(`Scout (${other})`, other), ); diff --git a/desktop/src/shared/lib/mentionDisplay.ts b/desktop/src/shared/lib/mentionDisplay.ts index 10d15643ad4..ec8a199efad 100644 --- a/desktop/src/shared/lib/mentionDisplay.ts +++ b/desktop/src/shared/lib/mentionDisplay.ts @@ -1,17 +1,40 @@ -import { truncatePubkey } from "./pubkey"; +import { truncateNpub, truncatePubkey } from "./pubkey"; -/** Compact only a bound mention's key; its literal label remains authoritative. */ -export function formatMentionDisplayLabel( +type KeyCompaction = (key: string) => string; + +function compactMentionDisplayLabel( label: string, pubkey: string | undefined, + compactKey: KeyCompaction, ): string { if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return label; if (label.toLowerCase() === pubkey.toLowerCase()) { - return truncatePubkey(label); + return compactKey(label); } const qualified = label.match( /^(.*) \(([0-9a-f]{64})\)((?: (?:[2-9]|[1-9][0-9]+))?)$/i, ); if (qualified?.[2].toLowerCase() !== pubkey.toLowerCase()) return label; - return `${qualified[1]} (${truncatePubkey(qualified[2])})${qualified[3]}`; + return `${qualified[1]} (${compactKey(qualified[2])})${qualified[3]}`; +} + +/** Compact only a bound mention's key; its literal label remains authoritative. */ +export function formatMentionDisplayLabel( + label: string, + pubkey: string | undefined, +): string { + return compactMentionDisplayLabel(label, pubkey, truncateNpub); +} + +/** + * The pre-npub key compaction a chip rendered before keys displayed as npub. + * Retired from rendering; kept byte-exact so clipboard validation can still + * recognize whole chips copied by an older Buzz, re-binding them to the exact + * identity their record declares instead of degrading them to plain text. + */ +export function formatLegacyMentionDisplayLabel( + label: string, + pubkey: string | undefined, +): string { + return compactMentionDisplayLabel(label, pubkey, truncatePubkey); } diff --git a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts index 7a051743344..8977c1bc4c6 100644 --- a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts +++ b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts @@ -4,7 +4,7 @@ import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; import { summarizeMessageLinkContent } from "@/features/messages/lib/messageLinkMetadata"; import { getEventById } from "@/shared/api/tauri"; import { getUserProfile } from "@/shared/api/tauriProfiles"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { isDefinitiveEventNotFound } from "@/shared/lib/eventLookupError"; const MESSAGE_METADATA_RETRY_DELAY_MS = 750; @@ -70,7 +70,7 @@ function fetchMetadata( author: profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(event.pubkey), + truncateNpub(event.pubkey), createdAt: event.created_at, snippet: summarizeMessageLinkContent(event.content), }; diff --git a/desktop/src/shared/ui/markdownMentionDisplay.test.mjs b/desktop/src/shared/ui/markdownMentionDisplay.test.mjs index bf96caeee00..5f9fe6b1126 100644 --- a/desktop/src/shared/ui/markdownMentionDisplay.test.mjs +++ b/desktop/src/shared/ui/markdownMentionDisplay.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { truncatePubkey } from "../lib/pubkey.ts"; +import { truncateNpub } from "../lib/pubkey.ts"; import { createMarkdownComponents } from "./markdown.tsx"; import { renderCachedMarkdown } from "./markdown/nodeCache.ts"; import { MarkdownRuntimeContext } from "./markdown/runtimeContext.ts"; @@ -33,7 +33,7 @@ for (const agent of [false, true]) { ); assert.equal( html.replace(/<[^>]+>/g, ""), - `Ask Scout (${truncatePubkey(KEY)}) 2`, + `Ask Scout (${truncateNpub(KEY)}) 2`, ); assert.ok(html.includes(`data-mention-label="${label}"`)); assert.ok(html.includes(`data-mention-pubkey="${KEY}"`)); diff --git a/desktop/tests/e2e/mention-recipients.spec.ts b/desktop/tests/e2e/mention-recipients.spec.ts index 0e29ed49327..0b81596780f 100644 --- a/desktop/tests/e2e/mention-recipients.spec.ts +++ b/desktop/tests/e2e/mention-recipients.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from "@playwright/test"; -import { truncatePubkey } from "../../src/shared/lib/pubkey"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -566,7 +566,7 @@ for (const { kind, scale } of [ .last(); await assertFits(markdown, "sent"); const qualifiedChip = row.locator("[data-mention]").last(); - await expect(qualifiedChip).toHaveText(`Scout (${truncatePubkey(SECOND)})`); + await expect(qualifiedChip).toHaveText(`Scout (${truncateNpub(SECOND)})`); await expect(qualifiedChip).toHaveAttribute( "data-mention-label", `Scout (${SECOND})`, @@ -873,7 +873,7 @@ for (const mismatchedKey of [false, true]) { .getByTestId("message-row") .filter({ hasText: "qualified clipboard roundtrip" }) .locator(`[data-mention-pubkey="${SECOND}"]`); - await expect(chip).toHaveText(`Scout (${truncatePubkey(SECOND)})`); + await expect(chip).toHaveText(`Scout (${truncateNpub(SECOND)})`); const flavors = await chip.evaluate((element) => { const range = document.createRange(); range.selectNode(element); @@ -956,7 +956,7 @@ for (const partial of [false, true]) { .filter({ hasText: "compact collision" }); for (const key of keys) { const chip = row.locator(`[data-mention-pubkey="${key}"]`); - await expect(chip).toHaveText(`Scout (${truncatePubkey(key)})`); + await expect(chip).toHaveText(`Scout (${truncateNpub(key)})`); await expect(chip).toHaveAttribute("title", `Scout (${key})`); const flavors = await chip.evaluate((element, partial) => { const range = document.createRange(); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index e9248509fd1..ee3731d16d7 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -import { npubEncode } from "nostr-tools/nip19"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { @@ -4535,9 +4535,8 @@ test("clicking author name opens user profile panel", async ({ page }) => { // Click now opens the full profile panel instead of the popover const panel = page.getByTestId("user-profile-panel"); await expect(panel).toBeVisible(); - // The panel's public key row renders through the shared widget, - // which displays the canonical npub form — assert the npub prefix. - await expect(panel).toContainText(npubEncode(MOCK_VIEWER_PUBKEY).slice(0, 8)); + await expect(panel).toContainText(truncateNpub(MOCK_VIEWER_PUBKEY)); + await expect(panel).not.toContainText("deadbeefdeadbeef"); }); test("hovering avatar opens popover, clicking opens profile panel", async ({ @@ -4760,7 +4759,7 @@ test("agent profile popover falls back to the owner's pubkey", async ({ profilePopover.getByTestId( `user-profile-popover-owner-${OWNED_AGENT_PROFILE_PUBKEY}`, ), - ).toHaveText("managed by 11111111…1111"); + ).toHaveText(`managed by ${truncateNpub(CASEY_PROFILE_PUBKEY)}`); }); test("human profile popover does not show an owner", async ({ page }) => { diff --git a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts index 81ff86c3299..8dbdbad005f 100644 --- a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts +++ b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts @@ -1,12 +1,13 @@ import { expect, test } from "@playwright/test"; import { npubEncode } from "nostr-tools/nip19"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, openNewMessagePage, TEST_IDENTITIES, } from "../helpers/bridge"; -import { waitForAnimations } from "../helpers/animations"; const SHOTS = "test-results/pubkey-display"; @@ -121,7 +122,7 @@ test("new-DM agent name swaps to its public key on name hover", async ({ ) .toBe(true); await expect(settledAgentNpub).not.toHaveCSS("opacity", "0"); - await expect(settledAgentNpub).toHaveText("cafef00d…f00d"); + await expect(settledAgentNpub).toHaveText(truncateNpub(AGENT_PUBKEY)); await expect( settledAgentName.getByText("Pinky", { exact: true }), ).not.toHaveCSS("opacity", "1"); @@ -159,7 +160,7 @@ test("selected new-DM recipient can be verified again through search", async ({ await charlieName.hover(); await expect(charlieNpub).toHaveCSS("opacity", "1"); await expect(charlieNpub).toHaveText( - `${TEST_IDENTITIES.charlie.pubkey.slice(0, 8)}…${TEST_IDENTITIES.charlie.pubkey.slice(-4)}`, + truncateNpub(TEST_IDENTITIES.charlie.pubkey), ); await page.mouse.move(1_100, 500); await expect(charlieNpub).toHaveCSS("opacity", "0"); @@ -262,7 +263,7 @@ test("selected new-DM recipient can be verified again through search", async ({ await charlieName.hover(); await expect(charlieNpub).toHaveCSS("opacity", "1"); await expect(charlieNpub).toHaveText( - `${TEST_IDENTITIES.charlie.pubkey.slice(0, 8)}…${TEST_IDENTITIES.charlie.pubkey.slice(-4)}`, + truncateNpub(TEST_IDENTITIES.charlie.pubkey), ); await expect(charlieName.getByText("charlie", { exact: true })).toHaveCSS( "opacity", diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts b/desktop/tests/e2e/workflow-local-controls.spec.ts index 6a8319082c8..0982da04b6f 100644 --- a/desktop/tests/e2e/workflow-local-controls.spec.ts +++ b/desktop/tests/e2e/workflow-local-controls.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -640,11 +641,11 @@ test("round-trips manual author and reaction message IDs through save and reopen }); await authorSearch.fill(author); await expect( - dialog.getByRole("option", { name: new RegExp(author.slice(0, 8)) }), + dialog.getByRole("option", { name: truncateNpub(author) }), ).toBeVisible(); await authorSearch.press("Enter"); await expect( - dialog.getByRole("option", { name: new RegExp(author.slice(0, 8)) }), + dialog.getByRole("option", { name: truncateNpub(author) }), ).toHaveAttribute("aria-selected", "true"); await expect(dialog.getByRole("button", { name: "Create" })).toBeEnabled(); await dialog.getByRole("tab", { name: "YAML" }).click(); @@ -663,7 +664,7 @@ test("round-trips manual author and reaction message IDs through save and reopen }); await correctedAuthorSearch.fill(author); await expect( - dialog.getByRole("option", { name: new RegExp(author.slice(0, 8)) }), + dialog.getByRole("option", { name: truncateNpub(author) }), ).toBeVisible(); await correctedAuthorSearch.press("Enter"); await dialog @@ -697,7 +698,7 @@ test("round-trips manual author and reaction message IDs through save and reopen await openTriggerInspector(reopened); await reopened.getByText("Author", { exact: true }).locator("..").click(); await expect( - reopened.getByRole("option", { name: new RegExp(author.slice(0, 8)) }), + reopened.getByRole("option", { name: truncateNpub(author) }), ).toHaveAttribute("aria-selected", "true"); await reopened.getByText("Message", { exact: true }).locator("..").click(); await expect( @@ -738,7 +739,7 @@ test("toggles selected author and message filters while preserving sibling condi .locator(".."); await authorField.getByText("Author", { exact: true }).locator("..").click(); const authorOption = dialog.getByRole("option", { - name: new RegExp(author.slice(0, 8)), + name: truncateNpub(author), }); await expect(authorOption).toHaveAttribute("aria-selected", "true"); await expect( @@ -751,7 +752,7 @@ test("toggles selected author and message filters while preserving sibling condi }); await authorSearch.fill(replacementAuthor); const replacementAuthorOption = dialog.getByRole("option", { - name: new RegExp(replacementAuthor.slice(0, 8)), + name: truncateNpub(replacementAuthor), }); await expect(replacementAuthorOption).toBeVisible(); await authorSearch.press("Enter"); From 55acf962a11cade34da4e1d9279d6473d4d95e4c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 14:39:45 -0400 Subject: [PATCH 2/7] fix(desktop): keep avatar initials distinct on prefixed npub fallback labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Astra P2 follow-up on PR-D2. Generated role-prefixed fallback labels ("Participant npub1…", "Agent npub1…") are ordinary words to getInitials, so every unnamed participant/agent avatar collapsed onto the same "PN"/"AN" initials — losing the distinct key-tail initials raw hex keys used to produce. - ProfileAvatar/UserAvatar: optional initialsLabel (defaults to label / displayName) drives fallback initials only; alt text, visible label, and the displayName color hash are unchanged. - ParticipantList and ChannelActivityPopover working-agent rows keep the visible "Agent/Participant npub1…" display but pass the unprefixed compact key as initialsLabel, so key-fallback avatars render distinct key-tail initials; authored names keep name initials. - WorkingAgentRows exported for consumer tests. - Consumer regressions: ParticipantList.test.mjs mounts the real HuddleParticipantsControl (CommunitiesProvider + relay-agents query wiring); ChannelActivityPopover.test.mjs mounts WorkingAgentRows with a real root and the production fallback-delay path. Both pin distinct key-tail initials (RC/04, not PN/AN) and authored name initials (AE/HR). Validation at final state (rebased onto D1a 2c68dddef): pnpm check, pnpm typecheck, full desktop unit suite (6476 pass, incl. these 4), focused mock-bridge e2e — workflow author picker (2), pubkey-display key-row/widget-scope (4), mentions profile panel/popover (2), mention-recipient qualified chips (8) all pass; workflow-local-controls:149 snapshot remains the documented preexisting 438px darwin drift (pinned-baseline-equivalent, not regenerated). Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../components/ParticipantList.test.mjs | 206 ++++++++++++++++++ .../huddle/components/ParticipantList.tsx | 15 +- .../src/features/profile/ui/ProfileAvatar.tsx | 14 +- .../ui/ChannelActivityPopover.test.mjs | 175 +++++++++++++++ .../sidebar/ui/ChannelActivityPopover.tsx | 20 +- desktop/src/shared/ui/UserAvatar.tsx | 14 +- 6 files changed, 434 insertions(+), 10 deletions(-) create mode 100644 desktop/src/features/huddle/components/ParticipantList.test.mjs create mode 100644 desktop/src/features/sidebar/ui/ChannelActivityPopover.test.mjs diff --git a/desktop/src/features/huddle/components/ParticipantList.test.mjs b/desktop/src/features/huddle/components/ParticipantList.test.mjs new file mode 100644 index 00000000000..d644c86507c --- /dev/null +++ b/desktop/src/features/huddle/components/ParticipantList.test.mjs @@ -0,0 +1,206 @@ +/** + * Consumer regression for the huddle participant avatar fallback initials + * (Astra P2 on PR-D2). + * + * `buildParticipantIdentities` renders unnamed participants with generated + * role-prefixed key labels ("Participant npub1…", "Agent npub1…"). `getInitials` + * only recognizes a whole compact/full npub as a key label, so before the + * `initialsLabel` fix every one of those labels collapsed onto the word + * initials "PN"/"AN" — losing the distinct avatar initials that raw hex + * keys used to produce (PA/PB, AA/AB). This test mounts the real + * `HuddleParticipantsControl` (room appearance) with the real + * `CommunitiesProvider`/`useUsersBatchQuery` wiring, exactly as the huddle + * room renders it, so it proves the wiring — not just the helper. + * + * Fixture design notes: + * - The self profile is a DISTINCT key ("c"×64) from the unnamed identities: + * a same-key self profile would replace the unnamed participant it is + * meant to leave alone, and then the test would no longer assert a + * genuinely rendered unnamed fallback. + * - The named agent comes through the real relay-agents query cache, so the + * authored-name branch of `buildParticipantIdentities` runs its production + * path (profiles lookup first, agent name second, key label last). + */ + +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +// HuddleParticipantsControl reads communities through the real +// CommunitiesProvider, whose storage helpers touch `window.localStorage` +// (fail-safe in the app, fatal in bare Node). JSDOM supplies a real one. +const dom = new JSDOM("", { + url: "http://localhost", +}); + +Object.assign(globalThis, { + document: dom.window.document, + localStorage: dom.window.localStorage, + self: dom.window, + window: dom.window, +}); +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, +}); +dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, +}); +globalThis.matchMedia = dom.window.matchMedia; +dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); +dom.window.cancelAnimationFrame = (id) => clearTimeout(id); +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; +globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame; + +// Bulk-copy the DOM constructors Radix references without a window prefix, +// so any component touched during static rendering finds the same globals +// it would in the app window. +for (const key of Object.getOwnPropertyNames(dom.window)) { + if ( + !(key in globalThis) && + (key.startsWith("HTML") || + key.startsWith("SVG") || + [ + "Element", + "Node", + "NodeFilter", + "NodeList", + "NamedNodeMap", + "Event", + "CustomEvent", + "MouseEvent", + "KeyboardEvent", + "FocusEvent", + "InputEvent", + "PointerEvent", + "Text", + "Comment", + "DocumentFragment", + "Range", + "Selection", + ].includes(key)) + ) { + const value = dom.window[key]; + if (value !== undefined) globalThis[key] = value; + } +} + +function seedCommunity() { + window.localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "community-a", + name: "Community A", + relayUrl: "wss://relay.example", + pubkey: "1".repeat(64), + addedAt: "2026-01-01T00:00:00Z", + }, + ]), + ); + window.localStorage.setItem("buzz-active-community-id", "community-a"); +} + +const UNNAMED_PARTICIPANT = "a".repeat(64); // npub1424…rcaj → initials RC +const UNNAMED_AGENT = "b".repeat(64); // npub1hwa…04hu → initials 04 +const NAMED_SELF = "c".repeat(64); +const NAMED_AGENT = "d".repeat(64); // npub1mhw…dmpv → key initials DM if wrong + +let React; +let renderToStaticMarkup; +let QueryClient; +let QueryClientProvider; +let CommunitiesProvider; +let TooltipProvider; +let HuddleParticipantsControl; +let relayAgentsQueryKey; + +before(async () => { + ({ default: React } = await import("react")); + ({ renderToStaticMarkup } = await import("react-dom/server")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" + )); + ({ TooltipProvider } = await import("@/shared/ui/tooltip.tsx")); + ({ HuddleParticipantsControl } = await import("./ParticipantList.tsx")); + ({ relayAgentsQueryKey } = await import("@/features/agents/hooks.ts")); +}); + +after(() => dom.window.close()); + +function renderRoom() { + seedCommunity(); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + // Named agent through the real relay-agents query the control mounts. + client.setQueryData(relayAgentsQueryKey, [ + { name: "Helpful Researcher", pubkey: NAMED_AGENT }, + ]); + let html; + try { + html = renderToStaticMarkup( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement( + TooltipProvider, + null, + React.createElement(HuddleParticipantsControl, { + appearance: "room", + agentPubkeys: [UNNAMED_AGENT, NAMED_AGENT], + participants: [ + UNNAMED_PARTICIPANT, + UNNAMED_AGENT, + NAMED_SELF, + NAMED_AGENT, + ], + selfProfile: { + avatarUrl: null, + displayName: "Alice Example", + pubkey: NAMED_SELF, + }, + }), + ), + ), + ), + ); + } finally { + // Drop the query cache (and its gc timers) and close the window so the + // test process does not wait on their handles after the assertions. + client.clear(); + } + return html; +} + +test("unnamed participants and agents keep distinct key-tail initials", () => { + const html = renderRoom(); + + assert.match(html, /Participant npub1424…rcaj/); + assert.match(html, />RC04(PN|AN) { + const html = renderRoom(); + + assert.match(html, /Alice Example/); + assert.match(html, />AEHRDM 0.04, isAgent, pubkey, @@ -453,6 +461,7 @@ function ParticipantAvatar({ diff --git a/desktop/src/features/profile/ui/ProfileAvatar.tsx b/desktop/src/features/profile/ui/ProfileAvatar.tsx index debbc42803a..d8d22eaa76a 100644 --- a/desktop/src/features/profile/ui/ProfileAvatar.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatar.tsx @@ -23,6 +23,17 @@ type ProfileAvatarProps = { avatarUrl: string | null; avatarDataUrl?: string | null; label: string; + /** + * Label used to derive fallback initials; defaults to `label`. + * + * `label` stays the full visible/alt identity, but some callers build it + * as a generated role-prefixed key fallback ("Agent npub1abcd…wxyz"), + * which `getInitials` reads as ordinary words — collapsing every unnamed + * identity onto the same "AN"/"PN" initials. Identity-aware callers pass + * the unprefixed compact key here so key-fallback avatars keep distinct + * key-tail initials; authored display names keep their name initials. + */ + initialsLabel?: string; className?: string; iconClassName?: string; imageClassName?: string; @@ -46,6 +57,7 @@ export function ProfileAvatar({ avatarUrl, avatarDataUrl, label, + initialsLabel, className, iconClassName, imageClassName, @@ -54,7 +66,7 @@ export function ProfileAvatar({ testId, untrusted = false, }: ProfileAvatarProps) { - const initials = getInitials(label); + const initials = getInitials(initialsLabel ?? label); const presentation = useAvatarPresentation(avatarUrl); const presentedAvatarUrl = presentation?.displayUrl ?? avatarUrl; diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.test.mjs b/desktop/src/features/sidebar/ui/ChannelActivityPopover.test.mjs new file mode 100644 index 00000000000..a3bd9dac9ae --- /dev/null +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.test.mjs @@ -0,0 +1,175 @@ +/** + * Consumer regression for the channel-activity working-agent avatar initials + * (Astra P2 on PR-D2). + * + * `WorkingAgentRows` renders unnamed working agents with the generated + * role-prefixed key label "Agent npub1…". `getInitials` only recognizes a + * whole compact/full npub as a key label, so before the `initialsLabel` fix + * every one of those labels collapsed onto the word initials "AN" — losing + * the distinct fallback initials avatars otherwise produce. This test mounts + * the exported rows component (the same one the popover mounts) with a real + * DOM root so `UserAvatar` runs its production fallback-delay path and the + * rendered initials are what the app actually shows. + * + * A profile display name and an aligned roster name are both checked with + * their authored initials, and the unnamed pair is checked for distinct + * key-tail initials rather than "AN". + */ + +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +class NoopObserver { + disconnect() {} + observe() {} + unobserve() {} +} + +Object.assign(globalThis, { + IS_REACT_ACT_ENVIRONMENT: true, + IntersectionObserver: NoopObserver, + MutationObserver: dom.window.MutationObserver, + ResizeObserver: NoopObserver, + document: dom.window.document, + localStorage: dom.window.localStorage, + self: dom.window, + window: dom.window, +}); +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, +}); +dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, +}); +globalThis.matchMedia = dom.window.matchMedia; +dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); +dom.window.cancelAnimationFrame = (id) => clearTimeout(id); +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; +globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame; + +// Bulk-copy DOM constructors React and Radix reference without a window +// prefix, so components behave as they do in the app window. +for (const key of Object.getOwnPropertyNames(dom.window)) { + if ( + !(key in globalThis) && + (key.startsWith("HTML") || + key.startsWith("SVG") || + [ + "Element", + "Node", + "NodeFilter", + "NodeList", + "NamedNodeMap", + "Event", + "CustomEvent", + "MouseEvent", + "KeyboardEvent", + "FocusEvent", + "InputEvent", + "PointerEvent", + "Text", + "Comment", + "DocumentFragment", + "Range", + "Selection", + ].includes(key)) + ) { + const value = dom.window[key]; + if (value !== undefined) globalThis[key] = value; + } +} + +let React; +let act; +let createRoot; +let WorkingAgentRows; +let normalizePubkey; + +before(async () => { + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ WorkingAgentRows } = await import("./ChannelActivityPopover.tsx")); + ({ normalizePubkey } = await import("@/shared/lib/pubkey")); +}); + +after(() => dom.window.close()); + +/** + * Mounts the rows with a real root and returns the live DOM after UserAvatar + * mounted its fallback (its 200ms avatar fallback delay runs the same path + * the app runs), then unmounts so the shared now-ticker interval is cleared. + */ +async function renderRows({ agentPubkeys, agentNames, profiles }) { + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement(WorkingAgentRows, { + activeWorking: { + agentCount: agentPubkeys.length, + agentPubkeys, + ...(agentNames ? { agentNames } : {}), + anchorAt: Date.now() - 3_000, + channelId: "channel-id", + }, + channelId: "channel-id", + onOpen() {}, + profiles, + }), + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 260)); + }); + const html = container.innerHTML; + await act(async () => { + root.unmount(); + }); + dom.window.document.body.removeChild(container); + return html; +} + +test("unnamed working agents keep distinct key-tail initials, not AN", async () => { + const html = await renderRows({ + agentPubkeys: ["a".repeat(64), "b".repeat(64)], + }); + + assert.match(html, /Agent npub1424…rcaj/); + assert.match(html, />RC04(PN|AN) { + const roster = await renderRows({ + agentPubkeys: ["d".repeat(64)], + agentNames: ["Helpful Bot"], + }); + assert.match(roster, /Helpful Bot/); + assert.match(roster, />HBDMAENW void; pubkey: string; @@ -171,6 +174,7 @@ function WorkingAgentRow({ avatarUrl={avatarUrl} className="h-9 w-9 shrink-0" displayName={name} + initialsLabel={initialsLabel} shape="squircle" size="md" /> @@ -192,7 +196,12 @@ function WorkingAgentRow({ ); } -function WorkingAgentRows({ +/** + * Working-agent rows for the channel activity popover. Exported for consumer + * tests: it owns the generated `Agent npub1…` fallback label that flows into + * `UserAvatar` initials. + */ +export function WorkingAgentRows({ activeWorking, channelId, onOpen, @@ -212,14 +221,15 @@ function WorkingAgentRows({ return activeWorking.agentPubkeys.map((pubkey, index) => { const profile = profiles?.[normalizePubkey(pubkey)]; - const name = - profile?.displayName?.trim() || - alignedAgentNames?.[index] || - `Agent ${truncateNpub(pubkey)}`; + const authoredName = + profile?.displayName?.trim() || alignedAgentNames?.[index]; + const keyLabel = truncateNpub(pubkey); + const name = authoredName || `Agent ${keyLabel}`; return ( onOpen(pubkey, channelId)} diff --git a/desktop/src/shared/ui/UserAvatar.tsx b/desktop/src/shared/ui/UserAvatar.tsx index 1b47bc4c92c..0398ea2ccd9 100644 --- a/desktop/src/shared/ui/UserAvatar.tsx +++ b/desktop/src/shared/ui/UserAvatar.tsx @@ -35,6 +35,17 @@ function fallbackColorClass(displayName: string) { type UserAvatarProps = { avatarUrl: string | null; displayName: string; + /** + * Label used to derive fallback initials; defaults to `displayName`. + * + * Callers whose `displayName` is a generated role-prefixed key fallback + * ("Agent npub1abcd…wxyz") pass the unprefixed compact key here: + * word-initials would collapse every unnamed identity onto "AN"/"PN", + * while the compact key keeps distinct key-tail initials. Authored + * display names keep their name initials. The fallback color keeps + * hashing `displayName`, which still contains the key. + */ + initialsLabel?: string; size?: UserAvatarSize; accent?: boolean; shape?: "circle" | "squircle"; @@ -47,6 +58,7 @@ type UserAvatarProps = { export function UserAvatar({ avatarUrl, displayName, + initialsLabel, size = "md", accent = false, shape, @@ -55,7 +67,7 @@ export function UserAvatar({ imageDraggable, testId, }: UserAvatarProps) { - const initials = getInitials(displayName); + const initials = getInitials(initialsLabel ?? displayName); // Animated avatars show their static poster frame until hovered, then play // the animation. const animated = parseAnimatedAvatarUrl(avatarUrl); From dcceac91dcbc4b2c555d67c4fd8235b83e576039 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 15:08:31 -0400 Subject: [PATCH 3/7] test(desktop): fold npub avatar consumer checks into existing harnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests-only cleanup for PR-D2 per the proportionality audit. No production change: every edit lands in unit suites and the two existing e2e specs, so the slice's build, typecheck, and unit evidence still binds. ## Summary - Delete the dedicated consumer drivers added with the initials fix (ParticipantList.test.mjs and ChannelActivityPopover.test.mjs, 381 lines): their minimal real-avatar assertions move into the harnesses that already mount the real UI — the huddle roster and the channel-activity mock-UI e2e specs now pin named authored controls (initials AL/C) and unnamed key-fallback rows (key-tail initials X2/SQ/RC/04, never the collapsed PN/AN). - Slim the npub unit matrices in mentionClipboard, timelineMentionCopy, identity, workflowStepDescription, workflowTriggerDescription, initials, and mentionDisplay suites, preserving the load-bearing cases: clipboard byte-exact identity roundtrips for new and legacy chips (wrong/missing/tampered qualification rejected), initials lookalike gating and checksum-valid key tails, authored-name paths, and workflow author/eventID boundaries. Validation at final state (tests-only edits; predecessor evidence reused, no suite escalation): 9 changed/related unit files 100/100; typecheck, e2e build, biome, and px text/truncation checks clean; huddle roster focused run green; channel-activity full e2e 11/11 green. Wiring mutation checks that failed huddle (X2 collapsing to PN) and activity (RC missing/AN) were restored and the rebuild was green. Known limitation, unchanged by this cleanup: full huddle-transcription e2e remains 25 pass / 1 fail on distinct agent voices at the voice menu, reproduced on pristine HEAD via stash run (preexisting, outside this cleanup); workflow-local-controls:149 keeps the documented preexisting 438px darwin caret drift (separate baseline, not regenerated). The last edit was a biome line-join in the huddle spec only (formatting-only, no rerun for it). Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../components/ParticipantList.test.mjs | 206 ------------------ .../messages/lib/mentionClipboard.test.mjs | 1 - .../messages/lib/timelineMentionCopy.test.mjs | 43 ++-- .../features/profile/lib/identity.test.mjs | 34 ++- .../ui/ChannelActivityPopover.test.mjs | 175 --------------- .../ui/workflowStepDescription.test.mjs | 88 +++----- .../ui/workflowTriggerDescription.test.mjs | 16 +- desktop/src/shared/lib/initials.test.mjs | 29 +-- .../src/shared/lib/mentionDisplay.test.mjs | 6 - .../e2e/channel-activity-popover.spec.ts | 56 +++++ .../tests/e2e/huddle-transcription.spec.ts | 55 ++++- 11 files changed, 190 insertions(+), 519 deletions(-) delete mode 100644 desktop/src/features/huddle/components/ParticipantList.test.mjs delete mode 100644 desktop/src/features/sidebar/ui/ChannelActivityPopover.test.mjs diff --git a/desktop/src/features/huddle/components/ParticipantList.test.mjs b/desktop/src/features/huddle/components/ParticipantList.test.mjs deleted file mode 100644 index d644c86507c..00000000000 --- a/desktop/src/features/huddle/components/ParticipantList.test.mjs +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Consumer regression for the huddle participant avatar fallback initials - * (Astra P2 on PR-D2). - * - * `buildParticipantIdentities` renders unnamed participants with generated - * role-prefixed key labels ("Participant npub1…", "Agent npub1…"). `getInitials` - * only recognizes a whole compact/full npub as a key label, so before the - * `initialsLabel` fix every one of those labels collapsed onto the word - * initials "PN"/"AN" — losing the distinct avatar initials that raw hex - * keys used to produce (PA/PB, AA/AB). This test mounts the real - * `HuddleParticipantsControl` (room appearance) with the real - * `CommunitiesProvider`/`useUsersBatchQuery` wiring, exactly as the huddle - * room renders it, so it proves the wiring — not just the helper. - * - * Fixture design notes: - * - The self profile is a DISTINCT key ("c"×64) from the unnamed identities: - * a same-key self profile would replace the unnamed participant it is - * meant to leave alone, and then the test would no longer assert a - * genuinely rendered unnamed fallback. - * - The named agent comes through the real relay-agents query cache, so the - * authored-name branch of `buildParticipantIdentities` runs its production - * path (profiles lookup first, agent name second, key label last). - */ - -import assert from "node:assert/strict"; -import { after, before, test } from "node:test"; -import { JSDOM } from "jsdom"; - -// HuddleParticipantsControl reads communities through the real -// CommunitiesProvider, whose storage helpers touch `window.localStorage` -// (fail-safe in the app, fatal in bare Node). JSDOM supplies a real one. -const dom = new JSDOM("", { - url: "http://localhost", -}); - -Object.assign(globalThis, { - document: dom.window.document, - localStorage: dom.window.localStorage, - self: dom.window, - window: dom.window, -}); -Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: dom.window.navigator, - writable: true, -}); -dom.window.matchMedia = () => ({ - matches: false, - addEventListener() {}, - removeEventListener() {}, -}); -globalThis.matchMedia = dom.window.matchMedia; -dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); -dom.window.cancelAnimationFrame = (id) => clearTimeout(id); -globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; -globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame; - -// Bulk-copy the DOM constructors Radix references without a window prefix, -// so any component touched during static rendering finds the same globals -// it would in the app window. -for (const key of Object.getOwnPropertyNames(dom.window)) { - if ( - !(key in globalThis) && - (key.startsWith("HTML") || - key.startsWith("SVG") || - [ - "Element", - "Node", - "NodeFilter", - "NodeList", - "NamedNodeMap", - "Event", - "CustomEvent", - "MouseEvent", - "KeyboardEvent", - "FocusEvent", - "InputEvent", - "PointerEvent", - "Text", - "Comment", - "DocumentFragment", - "Range", - "Selection", - ].includes(key)) - ) { - const value = dom.window[key]; - if (value !== undefined) globalThis[key] = value; - } -} - -function seedCommunity() { - window.localStorage.setItem( - "buzz-communities", - JSON.stringify([ - { - id: "community-a", - name: "Community A", - relayUrl: "wss://relay.example", - pubkey: "1".repeat(64), - addedAt: "2026-01-01T00:00:00Z", - }, - ]), - ); - window.localStorage.setItem("buzz-active-community-id", "community-a"); -} - -const UNNAMED_PARTICIPANT = "a".repeat(64); // npub1424…rcaj → initials RC -const UNNAMED_AGENT = "b".repeat(64); // npub1hwa…04hu → initials 04 -const NAMED_SELF = "c".repeat(64); -const NAMED_AGENT = "d".repeat(64); // npub1mhw…dmpv → key initials DM if wrong - -let React; -let renderToStaticMarkup; -let QueryClient; -let QueryClientProvider; -let CommunitiesProvider; -let TooltipProvider; -let HuddleParticipantsControl; -let relayAgentsQueryKey; - -before(async () => { - ({ default: React } = await import("react")); - ({ renderToStaticMarkup } = await import("react-dom/server")); - ({ QueryClient, QueryClientProvider } = await import( - "@tanstack/react-query" - )); - ({ CommunitiesProvider } = await import( - "@/features/communities/useCommunities.tsx" - )); - ({ TooltipProvider } = await import("@/shared/ui/tooltip.tsx")); - ({ HuddleParticipantsControl } = await import("./ParticipantList.tsx")); - ({ relayAgentsQueryKey } = await import("@/features/agents/hooks.ts")); -}); - -after(() => dom.window.close()); - -function renderRoom() { - seedCommunity(); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0 } }, - }); - // Named agent through the real relay-agents query the control mounts. - client.setQueryData(relayAgentsQueryKey, [ - { name: "Helpful Researcher", pubkey: NAMED_AGENT }, - ]); - let html; - try { - html = renderToStaticMarkup( - React.createElement( - QueryClientProvider, - { client }, - React.createElement( - CommunitiesProvider, - null, - React.createElement( - TooltipProvider, - null, - React.createElement(HuddleParticipantsControl, { - appearance: "room", - agentPubkeys: [UNNAMED_AGENT, NAMED_AGENT], - participants: [ - UNNAMED_PARTICIPANT, - UNNAMED_AGENT, - NAMED_SELF, - NAMED_AGENT, - ], - selfProfile: { - avatarUrl: null, - displayName: "Alice Example", - pubkey: NAMED_SELF, - }, - }), - ), - ), - ), - ); - } finally { - // Drop the query cache (and its gc timers) and close the window so the - // test process does not wait on their handles after the assertions. - client.clear(); - } - return html; -} - -test("unnamed participants and agents keep distinct key-tail initials", () => { - const html = renderRoom(); - - assert.match(html, /Participant npub1424…rcaj/); - assert.match(html, />RC04(PN|AN) { - const html = renderRoom(); - - assert.match(html, /Alice Example/); - assert.match(html, />AEHRDM { test("copy expands compact key text but preserves the exact label and identity", () => { const label = `Scout (${JOHN_SMITH_PUBKEY}) 2`; - const flavors = copyRenderedBody( - `` + - 'Scout (npub1037…08vj) 2', - ); - assert.ok(flavors); - assert.ok(flavors.html.includes(`@${label}`)); - assert.ok( - flavors.html.includes(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`), - ); - assert.ok(!flavors.html.includes("…")); -}); - -test("copy expands a legacy hex-compact chip to its exact identity", () => { - // A chip rendered before keys displayed as npub carries the hex compact in - // its text; copying it whole must still expand to the declared identity. - const label = `Scout (${JOHN_SMITH_PUBKEY}) 2`; - const flavors = copyRenderedBody( - `` + - 'Scout (7c7c7c7c…7c7c) 2', - ); - assert.ok(flavors); - assert.ok(flavors.html.includes(`@${label}`)); - assert.ok( - flavors.html.includes(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`), - ); + // Chips render the npub compact today; a chip copied before that switch + // still carries the hex compact. A whole chip in either form expands to + // the declared identity. + for (const compact of ["npub1037…08vj", "7c7c7c7c…7c7c"]) { + const flavors = copyRenderedBody( + `` + + `Scout (${compact}) 2`, + ); + assert.ok(flavors); + assert.ok(flavors.html.includes(`@${label}`)); + assert.ok( + flavors.html.includes(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`), + ); + assert.ok(!flavors.html.includes("…")); + } }); diff --git a/desktop/src/features/profile/lib/identity.test.mjs b/desktop/src/features/profile/lib/identity.test.mjs index 3358de5888a..089195213b3 100644 --- a/desktop/src/features/profile/lib/identity.test.mjs +++ b/desktop/src/features/profile/lib/identity.test.mjs @@ -23,13 +23,28 @@ const summary = (over = {}) => ({ ...over, }); -test("formatOwnerLabel resolves a known owner's display name", () => { +test("formatOwnerLabel prefers the owner’s authored labels over the compact npub", () => { + // The label ladder: display name first, then a NIP-05 handle, then the + // key’s compact npub — never raw hex. assert.equal( formatOwnerLabel(OWNER_PUBKEY, null, { [OWNER_PUBKEY]: summary({ displayName: "baxen" }), }), "baxen", ); + assert.equal( + formatOwnerLabel(OWNER_PUBKEY, "c".repeat(64), { + [OWNER_PUBKEY]: summary({ + nip05Handle: "baxen@relay", + displayName: null, + }), + }), + "baxen@relay", + ); + assert.equal( + formatOwnerLabel(OWNER_PUBKEY, "c".repeat(64), {}), + OWNER_NPUB_COMPACT, + ); }); test("formatOwnerLabel calls the viewer-owned agent's owner you", () => { @@ -65,23 +80,6 @@ test("resolveUserLabel falls back to the key’s compact npub, never raw hex", ( ); }); -test("formatOwnerLabel falls back to the owner’s compact npub", () => { - assert.equal( - formatOwnerLabel(OWNER_PUBKEY, "c".repeat(64), {}), - OWNER_NPUB_COMPACT, - ); - // A NIP-05 handle still wins over the key form. - assert.equal( - formatOwnerLabel(OWNER_PUBKEY, "c".repeat(64), { - [OWNER_PUBKEY]: summary({ - nip05Handle: "baxen@relay", - displayName: null, - }), - }), - "baxen@relay", - ); -}); - test("profileLookupsEqual: same reference is equal", () => { const a = { p1: summary() }; assert.equal(profileLookupsEqual(a, a), true); diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.test.mjs b/desktop/src/features/sidebar/ui/ChannelActivityPopover.test.mjs deleted file mode 100644 index a3bd9dac9ae..00000000000 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.test.mjs +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Consumer regression for the channel-activity working-agent avatar initials - * (Astra P2 on PR-D2). - * - * `WorkingAgentRows` renders unnamed working agents with the generated - * role-prefixed key label "Agent npub1…". `getInitials` only recognizes a - * whole compact/full npub as a key label, so before the `initialsLabel` fix - * every one of those labels collapsed onto the word initials "AN" — losing - * the distinct fallback initials avatars otherwise produce. This test mounts - * the exported rows component (the same one the popover mounts) with a real - * DOM root so `UserAvatar` runs its production fallback-delay path and the - * rendered initials are what the app actually shows. - * - * A profile display name and an aligned roster name are both checked with - * their authored initials, and the unnamed pair is checked for distinct - * key-tail initials rather than "AN". - */ - -import assert from "node:assert/strict"; -import { after, before, test } from "node:test"; -import { JSDOM } from "jsdom"; - -const dom = new JSDOM("", { - url: "http://localhost", -}); - -class NoopObserver { - disconnect() {} - observe() {} - unobserve() {} -} - -Object.assign(globalThis, { - IS_REACT_ACT_ENVIRONMENT: true, - IntersectionObserver: NoopObserver, - MutationObserver: dom.window.MutationObserver, - ResizeObserver: NoopObserver, - document: dom.window.document, - localStorage: dom.window.localStorage, - self: dom.window, - window: dom.window, -}); -Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: dom.window.navigator, - writable: true, -}); -dom.window.matchMedia = () => ({ - matches: false, - addEventListener() {}, - removeEventListener() {}, -}); -globalThis.matchMedia = dom.window.matchMedia; -dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); -dom.window.cancelAnimationFrame = (id) => clearTimeout(id); -globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; -globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame; - -// Bulk-copy DOM constructors React and Radix reference without a window -// prefix, so components behave as they do in the app window. -for (const key of Object.getOwnPropertyNames(dom.window)) { - if ( - !(key in globalThis) && - (key.startsWith("HTML") || - key.startsWith("SVG") || - [ - "Element", - "Node", - "NodeFilter", - "NodeList", - "NamedNodeMap", - "Event", - "CustomEvent", - "MouseEvent", - "KeyboardEvent", - "FocusEvent", - "InputEvent", - "PointerEvent", - "Text", - "Comment", - "DocumentFragment", - "Range", - "Selection", - ].includes(key)) - ) { - const value = dom.window[key]; - if (value !== undefined) globalThis[key] = value; - } -} - -let React; -let act; -let createRoot; -let WorkingAgentRows; -let normalizePubkey; - -before(async () => { - ({ default: React, act } = await import("react")); - ({ createRoot } = await import("react-dom/client")); - ({ WorkingAgentRows } = await import("./ChannelActivityPopover.tsx")); - ({ normalizePubkey } = await import("@/shared/lib/pubkey")); -}); - -after(() => dom.window.close()); - -/** - * Mounts the rows with a real root and returns the live DOM after UserAvatar - * mounted its fallback (its 200ms avatar fallback delay runs the same path - * the app runs), then unmounts so the shared now-ticker interval is cleared. - */ -async function renderRows({ agentPubkeys, agentNames, profiles }) { - const container = dom.window.document.createElement("div"); - dom.window.document.body.appendChild(container); - const root = createRoot(container); - await act(async () => { - root.render( - React.createElement(WorkingAgentRows, { - activeWorking: { - agentCount: agentPubkeys.length, - agentPubkeys, - ...(agentNames ? { agentNames } : {}), - anchorAt: Date.now() - 3_000, - channelId: "channel-id", - }, - channelId: "channel-id", - onOpen() {}, - profiles, - }), - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 260)); - }); - const html = container.innerHTML; - await act(async () => { - root.unmount(); - }); - dom.window.document.body.removeChild(container); - return html; -} - -test("unnamed working agents keep distinct key-tail initials, not AN", async () => { - const html = await renderRows({ - agentPubkeys: ["a".repeat(64), "b".repeat(64)], - }); - - assert.match(html, /Agent npub1424…rcaj/); - assert.match(html, />RC04(PN|AN) { - const roster = await renderRows({ - agentPubkeys: ["d".repeat(64)], - agentNames: ["Helpful Bot"], - }); - assert.match(roster, /Helpful Bot/); - assert.match(roster, />HBDMAENW { }), "“Build finished” to team-on-call", ); - assert.equal( - workflowStepDescription({ - id: "approval", - action: "request_approval", - message: "Ship the release?", - from: "release-managers", - }), - "“Ship the release?” from release-managers", - ); - assert.equal( - workflowStepDescription({ - id: "reaction", - action: "add_reaction", - emoji: ":buzz:", - }), - ":buzz:", - ); - assert.equal( - workflowStepDescription({ - id: "topic", - action: "set_channel_topic", - topic: "Shipping this week", - }), - "“Shipping this week”", - ); -}); - -test("falls back to the action label until a step is configured", () => { - assert.equal( - workflowStepDescription({ id: "message", action: "send_message" }), - "Send Message", - ); -}); - -test("key destinations render as the compact npub; roles and templates stay plain", () => { + // A key destination renders its compact npub (npubEncode of the pinned + // hex key); a checksum-broken npub renders the neutral label, never the + // raw text as if it were a role. const hexKey = "deadbeef".repeat(8); - // npubEncode(hexKey), pinned so a formatter regression cannot pass by - // re-deriving the expectation from the code under test. - const npubKey = - "npub1m6kmam774klwlh4dhmhaatd7al02m0h0m6kmam774klwlh4dhmhslezuz0"; const brokenNpub = "npub1m6kmam774klwlh4dhmhaatd7al02m0h0m6kmam774klwlh4dhmhslezuzy"; - assert.equal( workflowStepDescription({ id: "dm", @@ -111,46 +74,55 @@ test("key destinations render as the compact npub; roles and templates stay plai }), "“Build finished” to npub1m6k…zuz0", ); - // npub-typed form values (normalized on load) compact identically. assert.equal( workflowStepDescription({ id: "dm", action: "send_dm", text: "Build finished", - to: npubKey, + to: brokenNpub, }), - "“Build finished” to npub1m6k…zuz0", + "“Build finished” to Unavailable", + ); + assert.equal( + workflowStepDescription({ + id: "approval", + action: "request_approval", + message: "Ship the release?", + from: "release-managers", + }), + "“Ship the release?” from release-managers", ); - // Hex casing is not identity: uppercase still renders the same key. assert.equal( workflowStepDescription({ id: "approval", action: "request_approval", message: "Ship the release?", - from: hexKey.toUpperCase(), + from: hexKey, }), "“Ship the release?” from npub1m6k…zuz0", ); - // Freeform roles and templates stay ordinary compacted text. assert.equal( workflowStepDescription({ - id: "dm", - action: "send_dm", - text: "Build finished", - to: "team-on-call", + id: "reaction", + action: "add_reaction", + emoji: ":buzz:", }), - "“Build finished” to team-on-call", + ":buzz:", ); - // An npub-shaped string with a broken checksum renders the neutral label, - // never the raw text as if it were a role. assert.equal( workflowStepDescription({ - id: "dm", - action: "send_dm", - text: "Build finished", - to: brokenNpub, + id: "topic", + action: "set_channel_topic", + topic: "Shipping this week", }), - "“Build finished” to Unavailable", + "“Shipping this week”", + ); +}); + +test("falls back to the action label until a step is configured", () => { + assert.equal( + workflowStepDescription({ id: "message", action: "send_message" }), + "Send Message", ); }); diff --git a/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs b/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs index afa9da0ade6..9c96abd3539 100644 --- a/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs +++ b/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs @@ -170,26 +170,25 @@ test("describes selected trigger conditions on the workflow canvas", () => { ), "👾 reaction added by Carl to “hey yourself”", ); -}); -test("an unresolved author renders the compact npub while a referenced message keeps its event-id hex", () => { - const author = "deadbeef".repeat(8); + // Without a resolved label, an author renders its compact npub while the + // referenced message keeps the generic hex truncation — an event id, not + // a pubkey identity. + const unresolvedAuthor = "deadbeef".repeat(8); assert.equal( workflowTriggerDescription({ on: "message_posted", - filter: `trigger_author == "${author}"`, + filter: `trigger_author == "${unresolvedAuthor}"`, }), "Message posted by npub1m6k…zuz0", ); assert.equal( workflowTriggerDescription({ on: "message_posted", - filter: `trigger_author != "${author}"`, + filter: `trigger_author != "${unresolvedAuthor}"`, }), "Message posted by anyone except npub1m6k…zuz0", ); - // The referenced message is an event id, not a pubkey identity: the - // generic hex truncation stays. assert.equal( workflowTriggerDescription({ on: "reaction_added", @@ -197,11 +196,10 @@ test("an unresolved author renders the compact npub while a referenced message k }), "Reaction added to bbbbbbbb…bbbb", ); - // Both references in one trigger keep their own form. assert.equal( workflowTriggerDescription({ on: "reaction_added", - filter: `trigger_emoji == "👾" && trigger_author == "${author}" && trigger_message_id == "${"b".repeat(64)}"`, + filter: `trigger_emoji == "👾" && trigger_author == "${unresolvedAuthor}" && trigger_message_id == "${"b".repeat(64)}"`, }), "👾 reaction added by npub1m6k…zuz0 to bbbbbbbb…bbbb", ); diff --git a/desktop/src/shared/lib/initials.test.mjs b/desktop/src/shared/lib/initials.test.mjs index 2bbbcca1dbd..634489b9245 100644 --- a/desktop/src/shared/lib/initials.test.mjs +++ b/desktop/src/shared/lib/initials.test.mjs @@ -25,7 +25,6 @@ describe("getInitials", () => { // fragment that distinguishes one key-identified identity from another. assert.equal(getInitials("npub1z59…zwkg"), "ZW"); assert.equal(getInitials("npub1m6k…zuz0"), "ZU"); - assert.equal(getInitials("npub1etl…wnmp"), "WN"); }); it("derives full-npub initials from the same tail fragment", () => { @@ -38,23 +37,19 @@ describe("getInitials", () => { }); it("leaves authored names that merely resemble npubs on the name path", () => { - // Not a key-shaped label: wrong lengths, separators, alphabet, or casing - // must keep the ordinary name derivation so an authored name is never + // Not a key-shaped label: wrong lengths, separators, or alphabet must + // keep the ordinary name derivation so an authored name is never // re-derived as a key just for resembling one. - assert.equal(getInitials("Npub1 Person"), "NP"); - assert.equal(getInitials("npub1cool handle"), "NH"); - // Compact-label shape with a missing data or tail character. - assert.equal(getInitials("npub1ab…wxy"), "NW"); - assert.equal(getInitials("npub1…zwkg"), "NZ"); - // Compact-label shape over letters outside the bech32 alphabet. - assert.equal(getInitials("npub1bio…biob"), "NB"); - // Full-npub length with a non-bech32 character (uppercase tail). - assert.equal( - getInitials( - "npub1z59jp0d24242424242424242424242424242424242424242zhwqnlzwkG", - ), - "N", - ); + for (const [label, expected] of [ + ["Npub1 Person", "NP"], + ["npub1cool handle", "NH"], + // Compact-label shape with a missing data character. + ["npub1ab…wxy", "NW"], + // Compact-label shape over letters outside the bech32 alphabet. + ["npub1bio…biob", "NB"], + ]) { + assert.equal(getInitials(label), expected); + } }); it("requires a checksum-valid npub before deriving key-tail initials", () => { diff --git a/desktop/src/shared/lib/mentionDisplay.test.mjs b/desktop/src/shared/lib/mentionDisplay.test.mjs index 7365b853d9a..0f44d5131a2 100644 --- a/desktop/src/shared/lib/mentionDisplay.test.mjs +++ b/desktop/src/shared/lib/mentionDisplay.test.mjs @@ -46,12 +46,6 @@ test("legacy display keeps the retired hex compaction byte-exact for clipboard v ); } assert.equal(formatLegacyMentionDisplayLabel(KEY, KEY), truncatePubkey(KEY)); - // The two display forms are distinct strings — the disjointness the - // clipboard classifier relies on when it tolerates only these two forms. - assert.notEqual( - formatMentionDisplayLabel(`Bad Janet (${KEY})`, KEY), - formatLegacyMentionDisplayLabel(`Bad Janet (${KEY})`, KEY), - ); }); test("matching compact keys do not become identity keys", () => { diff --git a/desktop/tests/e2e/channel-activity-popover.spec.ts b/desktop/tests/e2e/channel-activity-popover.spec.ts index 0e6caac7762..59d46804615 100644 --- a/desktop/tests/e2e/channel-activity-popover.spec.ts +++ b/desktop/tests/e2e/channel-activity-popover.spec.ts @@ -344,6 +344,12 @@ test.describe("channel activity hover preview", () => { await expect( popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`), ).toContainText("Charlie"); + // The authored name also supplies the row avatar's initials. + await expect( + popover + .getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`) + .getByText("C", { exact: true }), + ).toBeVisible(); await expect( popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`), ).toContainText("Working"); @@ -372,6 +378,56 @@ test.describe("channel activity hover preview", () => { await expect(threadPanel).toContainText("direct thread link"); }); + test("unnamed working agents keep distinct key-tail initials, not AN", async ({ + page, + }) => { + // npubEncode of the fixture keys: "a"×64 → npub1424…rcaj (tail RC), + // "b"×64 → npub1hwa…04hu (tail 04). Neither has a seeded profile, so + // the rows show the generated "Agent npub1…" fallback label. + const unnamedAgents = [ + { initials: "RC", label: "Agent npub1424…rcaj", pubkey: "a".repeat(64) }, + { initials: "04", label: "Agent npub1hwa…04hu", pubkey: "b".repeat(64) }, + ]; + + await page.goto("/"); + await page.waitForFunction( + () => + typeof (window as Window & { __BUZZ_E2E_SEED_ACTIVE_TURNS__?: unknown }) + .__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function", + ); + for (const { pubkey } of unnamedAgents) { + await page.evaluate( + ({ agentPubkey, channelId }) => { + ( + window as Window & { + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { + agentPubkey: string; + channelId: string; + turnId: string; + }) => void; + } + ).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey, + channelId, + turnId: "unnamed-agent-initials", + }); + }, + { agentPubkey: pubkey, channelId: CHANNEL_GENERAL }, + ); + } + + const popover = await openActivityPopover(page); + for (const { initials, label, pubkey } of unnamedAgents) { + const row = popover.getByTestId(`channel-activity-agent-${pubkey}`); + await expect(row).toContainText(label); + // The avatar abbreviates its key's visible tail (UserAvatar's fallback + // settles after its 200ms delay; expect retries past it), never the + // "Agent" word initials of the prefixed label. + await expect(row.getByText(initials, { exact: true })).toBeVisible(); + await expect(row.getByText("AN", { exact: true })).toHaveCount(0); + } + }); + test("removes the dot and preview after the final activity is read", async ({ page, }) => { diff --git a/desktop/tests/e2e/huddle-transcription.spec.ts b/desktop/tests/e2e/huddle-transcription.spec.ts index 7c56648654a..32b4bcde56a 100644 --- a/desktop/tests/e2e/huddle-transcription.spec.ts +++ b/desktop/tests/e2e/huddle-transcription.spec.ts @@ -4,6 +4,7 @@ import { KIND_HUDDLE_ENDED, KIND_HUDDLE_STARTED, } from "../../src/shared/constants/kinds"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -11,6 +12,15 @@ const HUDDLE_CHANNEL_ID = "11111111-1111-4111-8111-111111111111"; const HUDDLE_PARENT_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const HUDDLE_THREAD_ROOT_ID = "mock-general-welcome"; +/** + * Initials a key-fallback avatar shows: the two characters just before the + * compact npub label's visible tail — the fragment `getInitials` reads from + * a key label, whose unit suite pins the derivation itself. + */ +function keyTailInitials(pubkey: string) { + return truncateNpub(pubkey).slice(-4, -2).toUpperCase(); +} + async function waitForMockLiveSubscription( page: import("@playwright/test").Page, channelName: string, @@ -1075,10 +1085,19 @@ test("returns to the parent channel when leaving a huddle channel in view", asyn test("keeps the huddle avatar strip compact and exposes the full roster", async ({ page, }) => { - const members = Array.from({ length: 11 }, (_, index) => ({ + // One named member plus ten key-only members (hex 01..0a + "a"×62), with + // the last as the huddle bot. The generated keys' npub tails are what + // key-fallback avatars abbreviate: 01… → npub1qx4…x2tc → X2, + // 02… → npub1q24…levs → LE, 0a… → npub1p24…sqjt → SQ. + const namedMember = "77".repeat(32); + const unnamedMembers = Array.from({ length: 10 }, (_, index) => ({ pubkey: `${(index + 1).toString(16).padStart(2, "0")}${"a".repeat(62)}`, - role: index === 10 ? ("bot" as const) : ("member" as const), + role: index === 9 ? ("bot" as const) : ("member" as const), })); + const members = [ + { pubkey: namedMember, role: "member" as const }, + ...unnamedMembers, + ]; await installMockBridge(page, { huddle: { @@ -1086,6 +1105,7 @@ test("keeps the huddle avatar strip compact and exposes the full roster", async ephemeralChannelId: HUDDLE_CHANNEL_ID, members, }, + searchProfiles: [{ pubkey: namedMember, displayName: "Ada Lovelace" }], }); await page.goto("/"); @@ -1100,13 +1120,44 @@ test("keeps the huddle avatar strip compact and exposes the full roster", async await expect(participantTrigger).toContainText("+2"); await expect(page.getByTestId("profile-huddle-control")).toHaveCount(0); + // Strip avatars stay distinct: the named member keeps name initials + // (AL), and a key-only participant shows its npub's tail (X2), never the + // word initials of the "Participant npub1…" label (PN). + const stripAvatars = participantStrip.getByTestId( + "huddle-participant-avatar", + ); + await expect(stripAvatars.first()).toHaveText("AL"); + await expect(stripAvatars.nth(1)).toHaveText( + keyTailInitials(unnamedMembers[0].pubkey), + ); + await participantTrigger.click(); + const roster = page + .getByRole("dialog") + .filter({ has: page.getByRole("heading", { name: "Participants" }) }); await expect( page.getByRole("heading", { name: "Participants" }), ).toBeVisible(); + const rosterRows = roster.getByRole("listitem"); + // Visible labels keep their generated forms alongside the avatar initials. + await expect(rosterRows.filter({ hasText: "Ada Lovelace" })).toHaveCount(1); + await expect( + rosterRows.filter({ + hasText: `Participant ${truncateNpub(unnamedMembers[0].pubkey)}`, + }), + ).toHaveCount(1); + const agentRow = rosterRows.filter({ + hasText: `Agent ${truncateNpub(unnamedMembers[9].pubkey)}`, + }); + await expect(agentRow).toHaveCount(1); await expect( page.getByRole("button", { name: /Remove Agent .* from huddle/ }), ).toHaveCount(1); + // The roster's agent avatar abbreviates its key tail (SQ), never the + // "Agent" word initials of the prefixed label (AN). + await expect(agentRow.getByTestId("huddle-participant-avatar")).toHaveText( + keyTailInitials(unnamedMembers[9].pubkey), + ); }); test("removes an agent from its menu without showing an extra participant control", async ({ From d814349cc8436a0479607626ae2160c321a16458 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 16:37:51 -0400 Subject: [PATCH 4/7] test(desktop): pin virtualization spec to rendered-window truth Tests-only fix for PR-D2 per Astra review of the revised candidate (approved 4b2a046b; the original 4d9add22 candidate was rejected and not applied). No production change: both edits land in the existing members-sidebar virtualization spec in channels.spec.ts. ## Summary - First-window endpoint: generated members have no display name, so the roster sorts them by their npub fallback label; sequential pubkeys with a shared `npub1qqq...` prefix order by checksum tail, not numeric position. Resolving the first generated row from the pubkeys the initial window actually rendered replaces the unstable `pubkeys[0]` assumption. - Tail endpoint: the fully-scrolled tail check no longer samples `pubkeys.at(-1)` (subject to window luck). The sidebar's own "Members - N" header must equal the fixture-known roster total (3 seeded + 500 generated), and the final row is located by VirtualizedList's stamped `data-index` on the last item, a fixed target a virtualizer clamped mid-roster can never render. Validation at this exact delta (tests-only, evidence reused from the approved candidate; no full suite run, no re-review): focused virtualization spec 1/1 green, members-sidebar shard 12/12 green, biome and e2e build/typecheck clean. A midpoint negative control (skipping to the middle of the roster) fails the new tail endpoint. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/tests/e2e/channels.spec.ts | 40 +++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index f9fd6dca58b..371cffdfb0b 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -4176,8 +4176,26 @@ test("members sidebar virtualizes large channel rosters", async ({ page }) => { await expect(memberRows.first()).toBeVisible(); expect(await memberRows.count()).toBeLessThan(50); + // Generated members have no display name, so the roster sorts them by + // their npub fallback label: these sequential pubkeys share a `npub1qqq…` + // prefix and order by the checksum tail, not their numeric value. Resolve + // a generated member from the rows the initial window actually rendered + // instead of assuming `pubkeys[0]` sorts into that window. + const generatedPubkeySet = new Set(pubkeys); + const renderedPubkeys = await memberRows.evaluateAll((rows) => + rows.map( + (row) => + (row as HTMLElement).dataset.testid?.slice("sidebar-member-".length) ?? + "", + ), + ); + const firstRenderedGeneratedPubkey = renderedPubkeys.find((pubkey) => + generatedPubkeySet.has(pubkey), + ); + expect(firstRenderedGeneratedPubkey).toBeTruthy(); + const firstGeneratedRow = memberList.getByTestId( - `sidebar-member-${pubkeys[0]}`, + `sidebar-member-${firstRenderedGeneratedPubkey}`, ); await expect .poll(() => @@ -4214,8 +4232,24 @@ test("members sidebar virtualizes large channel rosters", async ({ page }) => { element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll")); }); - await expect( - memberList.getByTestId(`sidebar-member-${pubkeys.at(-1)}`), + // Fully scrolling must render the roster's true tail, and the tail + // endpoint must be known independently of whatever the virtual window + // happens to render. The sidebar's own roster accounting — the + // "Members · N" header — must read exactly the fixture-known total + // ("random" seeds alice, the mock identity, and bob; this test adds the + // 500 generated pubkeys on top), so fixture or classification drift + // fails loudly here instead of silently weakening the tail check. + const rosterCount = 3 + pubkeys.length; + await expect(memberList.getByText(/^Members · \d+$/)).toHaveText( + `Members · ${rosterCount}`, + ); + // VirtualizedList stamps each rendered row with its item index, so the + // final item's row is a fixed target that no window sample can pick in + // its place: a virtualizer clamped mid-roster never renders it. + await expect( + memberList.locator( + `[data-index="${rosterCount - 1}"] > [data-testid^="sidebar-member-"]`, + ), ).toBeVisible(); }); From bd05f631fc1ecd10307ee85234db56a474f7206f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 18:40:11 -0400 Subject: [PATCH 5/7] fix(desktop): render compact npub for unnamed relay thread participants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jedwards27 review of PR #7495 (exact head a1ffa774b, 22:10:51Z) flagged [P2] that buildRelayThreadSummary fell back from a missing profile name to the raw pubkey, so a cold/relay-only thread facepile exposed the raw 64-char hex identity via UserAvatar's displayName label while the client-assembled path (resolveUserLabel) already renders the compact npub. Fall back to truncateNpub(pubkey) instead — the same canonical compact identity form used everywhere else in this PR. Authored profile names are untouched; distinct key-tail avatar initials come for free because getInitials derives them from the compact npub shape. Replace the review-flagged invalid relay-summary fixture ("bob") with a valid 64-hex key and assert the compact npub label — the value MessageThreadSummaryRow binds to UserAvatar's visible/accessible displayName. Verified falsifiable: reverting the fallback to raw pubkey fails the test. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../src/features/messages/lib/threadPanel.test.mjs | 13 ++++++++++--- desktop/src/features/messages/lib/threadPanel.ts | 9 ++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 8981d363803..630ebbc6fea 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -607,6 +607,13 @@ test("buildThreadPanelDataFromIndex matches direct panel data", () => { test("buildMainTimelineEntries renders a relay-only thread summary", () => { const root = message({ id: "root", createdAt: 1 }); + // Realistic 64-hex relay participant keys: a cold/relay-only summary has no + // client messages to derive labels from, so an unnamed participant must + // fall back to the compact npub (`truncateNpub`, the same form the + // client-assembled path derives via `resolveUserLabel`) — never the raw + // hex identity. `participant.author` is what `MessageThreadSummaryRow` + // binds to `UserAvatar`'s visible/accessible `displayName` label. + const bob = "deadbeef".repeat(8); // → npub1m6k…zuz0 const summaries = new Map([ [ "root", @@ -614,7 +621,7 @@ test("buildMainTimelineEntries renders a relay-only thread summary", () => { replyCount: 2, descendantCount: 4, lastReplyAt: 9, - participantPubkeys: ["alice", "bob"], + participantPubkeys: ["alice", bob], }, ], ]); @@ -631,10 +638,10 @@ test("buildMainTimelineEntries renders a relay-only thread summary", () => { threadHeadId: "root", replyCount: 4, lastReplyAt: 9, - // Relay returns participants most-recent-first (["alice", "bob"]); the + // Relay returns participants most-recent-first (["alice", bob]); the // facepile renders them oldest-first so the last replier lands rightmost. participants: [ - { id: "bob", author: "bob", avatarUrl: null }, + { id: bob, author: "npub1m6k…zuz0", avatarUrl: null }, { id: "alice", author: "Alice", avatarUrl: "alice.png" }, ], }); diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index 3bc5ee39125..b85b57d1ac9 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -2,6 +2,7 @@ import type { TimelineMessage } from "@/features/messages/types"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { isBroadcastReply } from "@/features/messages/lib/threading"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; type ThreadPanelData = { @@ -408,7 +409,13 @@ function buildRelayThreadSummary( .reverse() .map((pubkey) => ({ id: pubkey, - author: profiles?.[pubkey.toLowerCase()]?.displayName ?? pubkey, + // Unnamed participants fall back to the compact npub — the same label + // the client-assembled path derives via `resolveUserLabel` — so a + // cold/relay-only facepile never surfaces raw hex. This `author` is + // what `MessageThreadSummaryRow` binds to `UserAvatar`'s + // `displayName` (the visible/accessible avatar label). + author: + profiles?.[pubkey.toLowerCase()]?.displayName ?? truncateNpub(pubkey), avatarUrl: profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null, ...(profiles?.[pubkey.toLowerCase()]?.isAgent === true ? { isAgent: true } From 1143af345c0108245f84b413b95d6802c1196448 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 12:15:19 -0400 Subject: [PATCH 6/7] test(desktop): fix workflow step-text fill race and inspector box timing - addMessageStep fills the step-owned textarea (id wf-step-*-text) instead of the ambiguous getByLabel("Message text"), so the fill auto-waits for the entering step pane and can never land in the exiting trigger condition input during AnimatePresence. - template-variables spec uses the same owned-textarea locator for the identical step-pane fill race (the fill was landing in the exiting trigger "Message text" input, so the autocomplete listbox never opened). - message-condition round-trip waits for animations after opening the trigger inspector before reading operator bounding boxes, so the ~240ms inspector width transition cannot distort sequential box positions. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/tests/e2e/workflow-local-controls.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts b/desktop/tests/e2e/workflow-local-controls.spec.ts index 0982da04b6f..bf3aca2e2d2 100644 --- a/desktop/tests/e2e/workflow-local-controls.spec.ts +++ b/desktop/tests/e2e/workflow-local-controls.spec.ts @@ -64,7 +64,9 @@ async function addMessageStep( ) { await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Workflow notification"); + await dialog + .locator('textarea[id^="wf-step-"][id$="-text"]') + .fill("Workflow notification"); } async function createEnabled( @@ -153,7 +155,7 @@ test("inserts template variables with keyboard control and restores the caret", await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - const textarea = dialog.getByLabel("Message text"); + const textarea = dialog.locator('textarea[id^="wf-step-"][id$="-text"]'); const listbox = page.getByRole("listbox"); await textarea.fill("Hello {{trig"); await expect(listbox).toBeVisible(); @@ -287,6 +289,7 @@ test("round-trips and reopens structured message-text conditions", async ({ await dialog.getByRole("tab", { name: "Form" }).click(); await openTriggerInspector(dialog); + await waitForAnimations(page); const matchControls = dialog.getByRole("group", { name: "Match" }); const operatorButtons = matchControls.getByRole("button"); const firstOperatorBox = await operatorButtons.nth(0).boundingBox(); From 236af9e6137386737e84d3a474d6bc808a704c50 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 12:23:24 -0400 Subject: [PATCH 7/7] test(desktop): refresh stale darwin template-variable snapshot baseline The committed darwin image for workflow-template-variable-autocomplete was recorded at 9390e11c9 and no longer matches the UI it captures: the name-row enable switch is gone (activation goes through submit/confirmation) and the message_posted trigger renders MessageSquare instead of a lightning glyph. At candidate 1143af345, an isolated build:e2e run (dedicated port 4891) reproduced the 438-pixel failure whose mask bounds exactly those two intended changes; the diff rendering is byte-identical to the independently reviewed historical failure, and today's actual differs from that historical actual by only 80 pixels of +-1-3 RGB-unit antialiasing noise (0.009%). Baseline refreshed from that proven candidate actual; the focused test now passes, including all subsequent keyboard/caret assertions. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- ...ate-variable-autocomplete-smoke-darwin.png | Bin 104623 -> 103906 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts-snapshots/workflow-template-variable-autocomplete-smoke-darwin.png b/desktop/tests/e2e/workflow-local-controls.spec.ts-snapshots/workflow-template-variable-autocomplete-smoke-darwin.png index 54cc63d5b2a520a9fe9aff54caff40b69d25909f..f0826431ce64832ad04ab2ecdc829a2eacba214d 100644 GIT binary patch literal 103906 zcmZ^~Wl)?=(>9E|CqVGv!QCA~aF@jwf;+)of(1x$7I)XhT|>~tg1aQRyUVxNo#c6c zeN(l?SzGKmGdXArA`!g99V? zUP99|<1iE1__Z`inB~pnZpTg0WZ{j2?4J9vw9$_GZY!(H%NY;tj4lZqgm|v@32uL#OyN2nFfj|2;dy z;&lJtGvfc>vl@oyy0CAD3H!f)6-ZlV4b;$Rx8@5bNBZ++AgK&1ez!q@kIkgkcO))2 z5-Fs=vxs9+;lcXC?zY3Y9n7>Ur`FX=SF5&pH&upc#Pxiz*-?sd1zKjc^!{DoTfi&) zpffMnb+wAZ@eGnrB!S4rd7GbsO%=CLl5bI*v29xDYr3gdxJHsWs=7km82{X(~A=0@2|zv z-q$RfzzUUV<0t;w8Vi;|MJ6@VZ=(tAYntuWWTJ5lWF)|^LYpm?ziF5`*W*!gQ>@`r z6lw?Y)+2>s{S(0SAU>9pxLKWg7bW`^avN_AZjQ@Vut|Dl^eV<|H}l)YMVD)*Wc+u@ zy@D-SMxL$Xs=g$#J%5*glU0Il#EK{*y0kwxxi%I|9q6W?OIwJ>JIyy_R7Kta*3CiE z?wR-+F3@lzu`n!b%v`hIS02$hAa%7N(q|Pta3P2VMx!4WEsG%}^3{ zU1SnMlOKlk_XJ?DTP%oa0MGmQ3`SkI!?5l!yXY;$PyWU!?B z6kK3)689W1byWdEg2C zrIogjXp<7cn(R=Cm)rWmT4k&qi`C=Ng74R23&Hpmg7RZI+2(q|nBI@`zzV(PS4%9x zC08^*qH&Kh6W{p%GuGZhn3Sm3ddP>7%>abXT?PS9TyZAo8#4L54|D#c|W z*G)+;=0g${U|*hm{dkxYpf{fd!_U2$(o+2Mnq^1W7^NDmcJ|4eOAoOiUim44KPKA# z@CZ}=ui?g$Z0sGzh{F`mN%PVh|6s;m-cai#hqX)ZSS5uRA(HkG!PjZ-eNKR1%&g_R zS_Uv5n!Bikw(`;=5?WGHNA$Y=2c!Ru7W@R*g_$(4m6xBAh2G^ZhWV*jc7HAB*JLlz0eqm_BMmX%=0T9yOIE z;lAbnwqD2YtW~k)Y1oUxnP_j4N!emEPwiWXbe*+=zeTi%{pW=+&bTcM==JZ5GS%^t zNk9^la|23ZO^VsM&L(?0<|I=nKeYQ62d|X3Y^%F#Nq8%rwY{!NS^TMgR**mFwG0hE zUu7*g`2=476<%QVd#XT}`U2--@{hw`lJ4hTpC`LaB?b+{iD0sOgjC{m(Zon=@F_3) zO$U3oD0VYw}wZiZ^tAE_7^J7qm< za<}Nv6kq&cyT!4ZOLPl^MQ5?LBGf@Mru0){V9f;5skGN|2B#8{BHQbKrv%rCd`R{| ztjsrDQESUu*1~=A4nz09KE}lA3A_IzowooyZJJ&|ixxYK>aW<1WSW$p&5M{cO4K!4 zyOuF2cn`TpmFMdi>`j;2XTHxFGiOYdS6Ylo;hA}*32uKXamC~hY5O!SSV@5Guh&z= z=3mFAd7AL&)s}K4@5X}S`TcH$?43hlKsBSaIsJ#jFqTthAoF`g2%ex0lDLZ{e-+u} z;qZE_M0Ygdh;SFbGC5<_fq6mw{JKej{ko&U{%REfzC8c(0!;*lV&6dZ4K6^E_#vUV%W|9 zh*{_Q%__{*!EJX>VQ0>6&2*KfE*HQHinC zU=TXu-ABI??A;-kMdVATW{VdA} zeUN*To2~u+nlcR6SNtvM_jfxc2!zw&EZ+XiR}ztNt2O_dWnFUnvHa(YCaO<^no~z^ zQ3el&Wp#On0!jJ@MsG$x*o+0W{+$%r_`tULh{VEOeBJCBZkg*MjM#07k3W>nx8lm3 zw}IOh)ABdTl=BqICvr6-@uLAY;wCR25E1a^d#<9lYzZ+=z$V?ee|A-Ukrz9jfVXYZ z)L_4dVpwEo%vxNsb=)qr(W<&>jLdI3IVTYVx9zPBqzz8xQk+LQn&xhI?nQ|u|00f* z8Yt8{o9~Rt;w@#fY`Drt{)qHH#0k_Ckn!HifEcfIypn@tR_hB?CGj#JZ}Nj2stH`& z?ObihouXfNTQc~OB|2+mlfQxe2^l`aw6Sk~5FVxC@Q0N}j>$YA?kXyKt>MTqMece$ z)?J8=iow*)M*Y8K%O+RMRPHDv`5;FO!v-SZ=!BDf@ z(~@CwUVp`fW7c7+du`ZU3q$2-fKoGDyg_K5n+Lk+#nTtb!m?3O6&BPDBJ z$Hz@jai&=Kc+ymD4xd7~kBLpP!>oZAa zh9dD|lm?{&b$X}}7QwT%aWe3A>u22ydNA3U?=b{RoHov-qckHS8t;Y)N$~M)(?tKp zPU6GTVkU(&^t5yt{O;#w?H(r!r3=gx-NNe<^j@=!IzruF?}n&;QK2p3NQ{HlMD%Qo zIfQjm6f=Cdoiy)3maT*VSYJm*6xMckQzi=!65pxya=V;rs{GM&7^ma6m_+!}@eG2z zbSlq}p)3$8BHzP#lPiIu2|F5)7mPuiN13^Oir4eWW!Jh}};_#-%G`mp4JK z+H>N!zy|X)_y%Q)TLIMGY?mr13dH#9WMFWJXo}GAnf2ZVm`rxlE0fZT93BdyOZZ$9 z&xvV6m_DNY}rRtQgbo-HRmSSDXaCRZez* zD*cC^@n^vd(`@#)ic$Jhh0&AUoH&Ab$oxt~!&3TY5h!D^_n7wS^eR2XGfM+~_-22l z1H&niCG4e;%<8c_(Nj=RkYh*`-;k-F5bEh7dQa#ti|Wcv9Z!kg#e{x7*HiinJ4HEQ1sf`90c`i#FL z1Ak!ci`hmZ!oZ%|9mepZ3e88EW8_`#Cl(80&STwU&NsS042pu?w@ox|$Xa$$63$Z6{IZlW9z$@GaE6Rg5wwW~Kc55T&BrAX;s-`jmLcq*E$ zb}iMryGS@ud-e1}Xi z8l08cM5};Ej*qWVYv%UPaMv^9_#9V%55y#(l4|#4`CwL5yMd$aK$Jfx*OO#_GED)8 zrpLqVN)LgBsqm!6^gJLY=?)NB$7P(bZh#V*{JSJV1^u3O_Y{`RYXjb8jpEPqfE;lK z{1?o>+gT@-_{d2|w|%)cRmz}4dq#!Zq+1x&P{Css&BU<R$$kgpUxGECGX7`vguvIJdZ!yFzi3zSR%e7>-ICk-Pr6V+Y z)cJj5I=tO``G8{w0{r@q#L&-x5Hhz0rejl!w(b-Q0-x4=+~;~yvjN3w3_U6S4_x|04w z0;lM+J6{PN>kz4IQ%t-0G7U#0Ha)oVmW1W6nmus*husEbFBM%4=M5(0Qh-t;9!UJ) zMABk;kFd?c@%K82b5^kJi?@eo=H~9M@F8jSOLvfAoBPgadXxN?{HISXYNiwd-qR;H zJLAc0Dp{xCYIEH>|Ma@;6F1SR_LhThmwJUOd z0a7@f4%X}Lpdqj@Zv~SI{0x{nOvF#NbljBkj|^RT@{Vf7>n7S&8m%(AFAEjFN>y#0kG^flhrQFXX^W>w+jgC=uoiUL>s(@*MMQWGP z$2L`_WozYkrIk#`qW(i4SiVg=Rs8sVhe@~A%-Q+8YpF?Ni%BZ{iNk{&za&iY_XGKU zi^G%m6n7LpORLQlv7UB?0heoatP|91sI|QAFZUkr&XbtcoDUiUiS$K9F=WbGueqJTpPivX(J>-AI<3nIm&Fl-#Q8TSY-_#E3}XQxAoEK9Br9fO z`yP`dY2xWArezN3!-G%2Y9yJgS8W^=WusZl)c%s26#zDrxvHtDaowtq%?dD`FTZ>x z;Jb;^V9veKQy9yjoY5BnpVrO-n+*-CaPO-Dd`De=bN+{my69h_R>u zCnK?g!Tm;%0Jh zwB-f^^F}8N6)FuIzG4+vdcRP2$Ud$5+!#U#o?2XcmV@B4ijcsa*GPLRC$w%>bD&ug z-sk*%LKdUEeL^8Qf2!wI8ZGq=$3OP|ln zcG=@H>ep35jvo8?1#02#k8pdwCrnP~o2AI)#|=K?S@&1_ofoqQxL7w@E33X5$>reI z&x!8e_Ghwv8pUQtfXykd!L0?-QGHu+=zhO`pwe#7AeOuJMdFW14|Dv{PH|8IE%EK+ zdYCw}a*$c|`d3ZXECyeAEKCgGHKSwI`A4rEiF1pN^JtXD0U~`$4JHnHYHlIq#CJyR zRzLG(qDd)H7whGIyDkBJ)iAZ@Yh31jnhumql#jm>&Gr{bLRhJ_PhX_Daoev4^4iTe zyUbN(wz-!NCnhFNH~=aMOy&n-27q2i-;@id^yz0;OKI$vtLzW5>6CO^?YsuCjzBCA zQFi;vWJ@oiH{-Ml$s{9hDYP0sRaHwod5>*ql0)YAyI$Ol#-k1`H@5;#{BqFPM8w$7 zQT+kKmOs}gTeGGj&QrN8Wv>%p{I_OINM07+e`u;LZFB9+GRmV>A7V=ibCO|*|LdF< z(8w3Q$_W3?{V5!kS2_iX+WQ9Ecp+H4f{m?Dy>mvpXRCs8c!)ZTUsT@GpsR=O?Pd$k=Z2}VIala}jP)KTqBEkbDj6;njM)NybhLdGmtVhgLj3?R=7C!h zImOrXQzSoteV~6&Bb)zI?KUl8!Z_5JKwa8Zn$AtSQ~j=68pTTVm7R~^q+g{CwgdlA zE2eI?TbKmn4Hka)ht0{!3Ek2|nUfmz9eE{NqA;S8APveN=QHvAIv1^H^STO8_il19 zDjC98(Mt|3n;$)BPAk^7V;}Rsn`ahTNTkon2PEh|R<1HfUGHX}so)Div+E&0Be$IsgF=FXVH>y2{CZ6mw#HqrT7EL? zLzJ|5Z&;pw?WhNY^ru;F*PU1#cT_tNU#1dcwRFlqB1Xk(t})5iOMmkIz_F{CCt{=C zT;Pwe-jd)A8Lz*9P|U*Y4;ZrnRhR-TYEGVY8o5|`-hfV$)#H@o=ug2+Gb3AXOhhc> zGmd2stC?fEw*}n=RS_(=(Df>GevbElZM}*nr&;p8@|xVa7`mLMB2O`KK9l`nH6k6H@Sd#uk_sM&e7V zDB*eh{6#C|erUIlM|35{W3S(IzuiXRe!OT{Ww$^)Q(kTX?tJ{t;<4J{J-WuS=w70A z5#OaNPd7cWZ5kL%f_ABfOzFFZ4=*im(sBHeVPeRPy z>^fC6O0$Pfz|8*GKv^cyi*#k3z^dN?S5Ie~mPOsNfN_`o2eAgL`04CJf+nC?m+{f! zy##Uefvr!+2T!w(i3WA7gXFAVc0k_IIqrQizdUhYhrDiu~AE^Cg^`LXl4O36Ft z-~G{@ziO2W{GXf&it?l*g*{(HH0YjjxCN?{%d{8+1n={CEA@^S+B|-3GNki5T2D4_ zsH_DTwz@JbK3t#9u~q^-#{h~c?+C!B}d#R@I>tYK07lM1itjmbC?Y5HaZq3NHY-0)m!~kTqq_J_QLei)cD5* z6fkO)@q?U(HWZseP?64Qaj%pU!Eht1<3V;Oy9wKH z7m-1$%uLbsA2GM-^!#)K)N+#PN22RQt2Em`x zGK*-?Q|D(H0T+>=5yFkGa3Rw)an|FOcZSN-lFT|I9}2!A6PRNO&LeZ2yD&2=;R0A1 zFK>pK%L7uPOg9r$Q|mPGm!3I&^MH~O+WsZcm^e^WWpnK$I5T)E$C0_~NABcXawc7Ze>DU~dN%(!+9m_sJ zJELwnE=_YRuUyeBrW8J^xBk>9++>%sV{yp*8j0&lUN{YD&r`~NaMzrycO+%X=%L#{ zMy?IJph(MQA>zACl!~Yoh6W$!`^;T2NQK&82R7J9D(GspTg|unuuknrYp=c)XF4$N zMZcbf?Adyxn85ap`#e1VGiEbXn`UTJSA*AxMNnHDs(h zE_ZrR9p(ZhTC;U-q9qF+OOXgs!m7{N5bJ++@wfULHpXM#H}}}n;P_JcO>YCBg{={vK&^hg3yq5mY*Y|f z5*zs&!uF1TSV4(6>K>~9?8TEaOGr{;65;qam^;Waz1iTdnZs^!w_7Bp5KDc>n&_-` zx0W`ug+Wk7aa*SDs~(xy%Z{B#n)nk9O9coMXEXEr-jMu4b{_;wv?TSVpp`$b8JB za7^CeCKY5{ArQo^M~mQ1p;rTlQ$M8Ft!wIiNXu+P5S1#%nMk#k0WBq9aQ)c^O!GRC z&@trg)xlR}R`r5YLyy(5G4!52@a2yWAmBTrV*u5>W9d69h1PCKr8LfqB!;OEaZeCG z=HUUeV=ImNGma%Ykz%x%aQtrNXQrk5tr1ZA^)RX+-}ex1NDR#P=H56Ufd{7};urrA zk$>bD{oVQY(arnpaEkUdeK}%&US2xK)%HW-kKkEQ|pZ%+88WnYc?`?qqwRX;h#-8bt{yg=AAo=^Vx56si-_%k%JWrexhPdxl{ z`4*`_w2#4^%nHh3u2~t%TuI)&M4RQv%v_d?z;O>8MIO_z4P9jBT;zG${zFNpZzf%D zV3WdJ3xi zr`yvw|0m>QF{eQDv<{yeH2^f&__z~C3C09AKC*!X)Ya8jNzQ&k6Q;lEt7WLjdGgS@ zW!ILk!jl3gKdpeYupYsRFQZBd8YjD6Q;p`AdiO6^A#ytdE8Nu91;LpO!VM6kzJuLW zSCph5mUSfvKl=r469Z{RXZV)uy&&4|V^;Gy&q{o&PWL0JTIF-^{ijPfvOBpw-$6hX zcMm6Bb5-98wtf{tw83{mhgD3hZC{NDyp^Bys+z&?%qg-yDu#?yT$3F+|$;HUW|ESv_zj~=NBdGB4EvcaXSPA(R%T zp`)vH`7o-$TC^1-wnGN_f(|VbQ96_9Hf|Q~WSgcInMYaFlRwYCyy54Y$R3eyU|Xqo zz)w=i;OE#Vj<{Kw3jPDevAE1c^A#?@Ax9OQ`cpqc9%lSn5rJ3gmR5HF)bI9{B6JX zV1qj!Oui3ff9*KjKOkwcy0{|*mU@@9+%+E8ms}p8-RJBqNU$}w9hNO7^HK`=LCHza zHhHMW%3BKz8`bkT_Ffv++VfeL8yYHz6f*F~B%7=oe+5ky+})o90$^vjtrp9?(y`#P z10u{rZf`@NoT=UPuFAB$_Lb%FDZ|Aao=8AKQXzwgW2&Hf$ltpGxMK-wTkN{Ys3xv6hiL4*BNWM z{{K5qaSK(}>ez1o%)i1$76;&g1vK@w42r%H&KU$_rU2DGM(5-h8j+ zA=y`h%j>$I1yu5xk}#Rd->K%TA4nl*3wyHK9J1!~fH~E2C!O8Alm5%!)RtT!9h1arxZfKi>i@jeL(%bqm0+rijOBd>axg>|etK;^=7mmB z`$fQC_!-Kzml;*=!{4m*s=wTUShfBuqZLOaJc!&i3;u3q>)N|{Pf<*9Zd zd+mPBM;eV#dn!)s=`cqrowtj7cVWtM?D1sfX0K!}tlifgeCpWsa(18YDCYX&H=4%l z_4~*Ji#54HrCvF>=%vDN=noq4GZmoP%}WKfkfSqq7z=Tx`6Pj@ zn@bBbNLA#Ah{jC$?c7pPX9)AEr?QdfTS&(~Zy$}G<2JhwY}87Z%lnMhl1_f(VQWb# zc%4+PS*2)hIae^GA$OpBdtClK6s?@Jsp3@ ztei9-^*o&9Iv?rr5VGnCd#pYxlPwmyM?y(SK8NYp>14caO{=9Mx1P_4P#bqS95ZZ7 z9(fUAWLzgK@@;Xhe+^~nA6`X7M3bMNqn88Rn$xRk=t{CWvt))A*o&{seh%7YmHPG~?9xmHfbx!W>ceeRVgp+B`x4Mzs zHC?tILH(cT$(!dz9Ib%I+q1c~M+!f?I5x-CI?vFq2Y<<41-7H5V#1_siuo6i&{%(6 z;aYFn6*0q=g8IuO9ZSw%qc2jE@=SWvih}lDj>{_;@EN|=(4g{~cG525-KI>J;Mqel z_NyU3gTUWNdy|vyUx>e5bV43L_h4O zX9{^_$_cyNU#8N1BNlAipDC?GJHIMTyoS_nWCvKSfV;>|6~!*09T00Tw{;3T2Xcb5 zrG^L5u~Op1+8G7rUa z*_H2~?}a87Y-b!RUV~M;zNLfp8#4ZQPp3wwr8%v9k1N8dbdxP2t$W2F^|qp0&XrU@ zxFT{vLcn1K0`96hNfw@EZfipL>(4O|_FUcSqdCFLiX8$xLnsAsciIv7s2GZl-ZU{m zHLPkgjAPoyGV@l&Amf61B!ON<-C2+*SjLxo|38=c+yV=Qv}8U1*A{{8|#P&v5W?sUS$cCJ#)`O@m6`p1FmBP!tsX~G%fxEkY)U!p3a zD_v^|_Dl7QM(y(lj_n--37t$TtHR0xQ0iysdSXE{%apUDT1+Kv{mFDNSw%th+|F|B z-v0h;s^7yEz29vwAFvvw`8lmTKR-X)>$;`%75!^Q&Dg@bi_vVc7cGFn^VE-_tAkle zTMsB(xypNKI-~NQK>g7lZxZ%Oouzyjs^oI?I}uZc(sDXTlvizMC-~>Y>ic{)o&G%2 z(0xJ2;K%PMd;*LhpYK_6Cyc0ejF&}wslTot4Q##P_%j%6oe21y1&)6AnuIXy7iDl! z8$7xYY`4k77jk4Q10tau&5J;Q#Lqlw&dUS=r_;AyeueU&g7Y=oIS_+Je_PZ0h+q0m zPY0E$T~2Cq>4Lz3qZv&)TfRZ!LUbWF!SynE&^rkSn!9djS->1u14_YwSkhjEpOQ@n zqCNH}R^x1}vqhw0f%4HeP&BXPO=fv1w_B(kTjjC643UE}y3mS;@61+Hu`lP~vZqelq21`o8UU^|sw`uC&8+hl%D_TZy=YXc9FW}k$jMk? z(2y1}=yH}X76nK#;@j6v;&WV($KryZs9ch;9}sdJaH8(DVx~LjBrFJo{8i=ZBYlTA zM(A@?YLKKyo&p>Y>#9p`& zJ;&*~m9DL?>Fz4SaD>OYDYOsu&E=pv0=y72J>@jctKAw?|1RELheKaK$?p+>@QxbW z8wa`p8~>Z$5LxdBr_Eg`$}7H@Z$nYB>gp>Q?P8OC9kf8osUX)wCl_9=){6vfKq$H{ zh+chMYm?|gz-Q9Txh|i3WGRlFrMp-WIexU7-qo$!iS3ff9JzIyg;oTnIv6L7#ZvOIK}1}Z z_KWSQ&`>|E!FHwq-Djh)hY{%N>-!z*nzaR;AhY%bdNVi=m?V95%0j82aX-LGx=p#MA#$ z@IYp11|tE}DtFJVWLI~;$$i?Ea$h3~O2+BnTsvdEPaD=P1WvOICYHQFl}y92c{_z; zS0#zoxiLqw>S`R?!`(%kP$sUhPvbB)kfT25*({`MUWkp6xBvQg>8SLkV<)+lFd9Hf zkyRX(=?8!In{h82&aCpPqsN;?x4mnPN}D8T&cb1?;?Z_j1QBw(Jf=;(oX^J= zd3cOc(yPzpeIhJt`914!Spf~%C-%w?&?wqY+ zISo4Hth`~(g+{mXMteQOPf9!6_JWVk1It1VZM3_G1(E6XR$TW9HQ>&3s)e%E4!#vn zzl8p84lNzX9n23m6gg=gtHjF5i$~YT-_n|j%qJ7GwIH{*xAYH~B}>)1zX|L=_&*Mz zk7KGXR8xgyEImJEy*XbzwSRF?x!GB|>K~JnlY=IYhN|>XOb)oh>xj8Om z4DaH-UC)&Q7%bf_j`2M_%|h6)K3YDYdF=U06-OUYc-MOzxF`SgLGXHJ{z$-L1Z{Xr zx=uEHI-@r&Rwn(IFbZbSXe)X(Gu9v5LIie5Vrv20AD)AV+vO>M12b&Nu z*5Rv)>^uD;R|5lsMHVrlhr1s`8pV&7z0l<3e0wOLlSSq`w2=aBlPz|ROO@Af@L{FJ z1rP*x$3+ucyMI_37aJR!k)b%u?n?OdZpd6K0i$M+|I;}QQSl{02bkun&ho_Qeu+V& zh#P{Y{&3gHeXBJ&lpS#XX_t~7fgak@h_9&vPzadM6^ny<0cm#0$S%*bk5JxGY-0d4 znhwpKqyOfo(suLJJ-Ak&&n~UNBw9U}u2asV6hk4nw6`40uWNC7^iUFBM@OD7EF@~S zvC}#2H&!1sIma2%hq87%(&}qRZmHCIa#lWRHmdU%_l>6}J-;ztsaX-LO9E$JLJ~mo zh+ICw@#dR+eD#)xikF+C?~ZtzVm93!8^60DvU~WM$pmIMZA`-9Q4-BiN?=*GmT>eE zQPByeEn8T+43AG0D`pZbR(PKMl0xnpti3-hRm%}COUw2T=GvUaySv!Z$hQx^XV@V! zRD7(nzDRfMB54da0e6yWfAc|ox{Eh@Ishk$DW=@6E;)vX&~(w%i)!LAjs3#c#+|n8 zVKH?4TUIj@2~&2ZFm2(;^WT*l6hpVj3#vYwEByL({#sOj zC0=egiDjzbQ~rc^?U#j6<)FvAUQrJ!I$n@@1|SS$sou+FZM8$t&F-&q!VK@$2m8t` z#pTdm03r?4osg)J=d-G{2V@~{td_1D`W(dxtN^HWE{gY*6JJ|CjUCtbFWPEfP%MGi z=k`huJ$C}>KkgD`xqe{JHD&SPu6{Ld*8(^HtB1T!ux4^P`d8;;E%M)19hg9u@i0u1 zlz~Cbpa~nL^t=*3`P^ICBgR+!Z|{;GDlHLt?e=WbOq?~%sSpHp?Jd|57Rjzja{O6R zQ{+Vf5AEvHWl?7C1Z+|tTp@uDnSezyuES5xsUjis-y#~{)CCV{RrZi}=|(|g<3D5l zk?OZ!!+`Cu@0cLme02uh$T$1O!dzMSBbu&BuL82G^sQ@0ot8IyZ;Zzb5i8zoCYVHb%8}N*4D@agKVT3J%StJ`EWShbcBc8C*P^!LrxGM)s}$7)Y2M3K@8G zSjAgkb#}BDjlQGFeD#pwO}9kwZ0N{L|DiD65OjtN*j9rGqXwNxSnX)OCQCMT3iuMs z?<`keF&%V@`i?Lb)RFYFIDWJuo2R+VQEX>P^pVptb4-6RPU-%;AE(YUWdk#mB-{D8 zaY1R&a<3wNqpq!l-&ayj(#9qi1#dW=a%Ia|V9UFigQiEpiv}{pN>YQZj|UmXmyB0g6^tVS`Vy*rfHoFh-UWQ&F zOPMX&GA#&TVKCfk+KM!QfD6FH!lQP@3vwc4DUN!bIg=`u%xxAyf6poq9iRR=)oZ$9 zx+A)FD{Ny313%BR!)L*ZW(CKJ=N15}M`gxmt9;bEzkdRWtkoP15_{8`GUzGkT60>Y zngvdUSvy%$(XOPwQmq^{gK#|qj+@%tsPksz{$0ikya+qDwy&GZIvz~uUErGFYu`Gs zCiuBW<;aYU!Y?;5R>D30dLjz1lF>ky(WvD*!H?<4Sg0tK!=s8Czs~OV%|$uAT?A7s z6VLCsLS+0X13d#K6NebuMxI)HZXBJRH>QK1|J_)7!7XLjs4oP6a&Q)vaccR9uSQ_y zLqM~Q0ckM%Ws!s#b>KST(9bA!w_&c?9?B)RL~i`Q24r-T@I68K6vQyTx);^sB1xnoiC@P~2KuN6q}o}vH}5D-Fdg`N z#?GCrS2$1XIBZj0(PkXf{*W%24nWVG`KQh> zf;O8$uzXfu;!MR?3l!tO=5ZS{C|WZrJlL_te5kGI4R4K^WHVgNQ(kV@$1Ouw3hZL2 zFV9#W9DOQ81T*NRICLt+ z@vmHR@!?vnot5Y;C7){Vy>~ zM47(0#Uq_%m9UUN6G50*iW)LQDhrxt@B_x#FV=?>a?v(&ApKKM=QlfgvKN{*dFn(m zwoQPZ0YN)mfo{52#3>K5QO*J(DXbZ0W!Z>h@FR(bNv#0kTi+tQ&LZo1hMZD(DVoT} z5QIiEojO@Kr6qOQsD+?Cb`flLf$p$O`LsRU?uozG=wMEPzfl$st#Ey0M`5)Ab}9$u zra6TswrJ^dcrl8|(g|z|OdkH!^o~gwL9E*rEwLQVg8ZM3n;8RJsXe^@HLf3<`E#52jJ#O8?&E|*%+uoZ5eBdBmQaSS|9@cUC=llgA78MtC*V8JH2 zlzAdd$Sa=fbUtz5%k)?nN+?JUtSB_Spc=XOdJk;oU?p_Apf&jSwJ>AUW7A?pB8);` zF{91MjRjQLJb0=pHTYfH)Xht3nLCmfCiT->!{(q1Tkdq&QKS#DU}VT1h62 z`E(RID8;nqesT0Q>NdlIeRWFxHCI@3pOu^Y| zl^?Cu*LUR>y}pjLDh8O>Ltv~Q9#KC3!-b21mTH&rOz9C3Tj@=>ls(nWoT#bo3X>FV zE1x1iAOR^A`5R=i6wyVLfQ`muZOLWhFd^E1Wy`>q&vy75Ba>0D$6{d!JT3SvqJJa_ zPMdAi8i$Gq@8-EC)@f+Zk)>$;KMc7xRz=MYO~br6 zKSP-^_}8I7=x(M?UtG%gZJKdh9j1+Jer*S5{-1Wob_`YZgH6~Z0T%KwmUt-o|ITRV z+lx9(HrX5O{|fej2A^rw_{5r_bx^a{y;%{niPaT!LNQmE*yBNP|1-z+&-Apkp$gFF z43@D0%S*Mi_#V;hm@TUBUoK=3s?$>hT(d74NiUx>>O@igH^(5|gdbxm5Gp@iv?1dz z8bLw9@P$TQ`TlKKcW$Rv3Yjvb`s?<_4<#Y)X+y}CeVe3R|LYxi3&?d)fyPG}T=5AB zet00JA?O_jk*hcjcbZSth<3Z)xxP~LFPKTqWtI*IgSKk*p*4a(4g{oV{*%Z0XV`s0 z7$<*KN1h!$_?I2(5U=L6uc+%VR}iu%lC+edtdhmq;khjAlp66H{v_3j{g_d>545cW%2io>|Dn89C`-l7dDmTZA@O|z~b#G5z6^;lKsZuLAj zuA>|EK=lp~2?5P@#-b+khI- z#}Jfv_6qWETM}A_8$Qm+8d!WHq%#sNbnkm(B=y#eJpo)iQ5WXXT@~%N-MHRhCo&4B zRmC00QYET%s?$lZvkGJV-vR?kF`*pPy4XeqVs*T!fQ0Y?lWT~n<|f`Gma0YceZysw z=RGK~j&UVPwTq6g5QVozv{>f36{#w|?Ys_|k5q5ZFjXS04gM?UcGd@qKcqV=%v&f# zy`Wxho2Jo0-U=-8DT{3{Df#xn*O$|pmAg8!(1!JqG{twpO@1$02Bpp8u*K2)fjIrI zg1$c3|9?E4bzBr$_w|Ps0kLQSm6C3eR_T=Ply0OOlu)F*LAtwh0O{`T?izZC_uzeg zFMr;TcbFMw_St8z?^=6tGOXS2ac0))>5v}PJGG?9H#h(KW4y;8p~LIm2ji&!=TGw! zuWL~>Ue{Z7_RAQ-9GMat8x1}d-qkSOSU>6$Wq*<0475=@Dh`%cXS7)QS7{RT=A4?d z9(BJHC+>>5YM;d7@>5|v|G%TuO#!qKBZ z-H77}YVE%r)Yw?^Ctt>QTL_sGoXYseI1JOZRzNB`8^uuSa~_r-5#fi>%C& zRFTXjb_IPKS5c8lzp*x3)*z4ee-#0eo1USka4q#Cpz6p@uplzs^c_<<6#^wXUHtDg z<*(HyaW5x8|^xT9qamw8*!7wvXM%7bePH<0|5vU49BmD0vK|Vg^IhzUOY+fj8 z*qQofw>}2#d%deH$diETT>lw^h(>KXT)^jy>8qob?m@RlrpS4o&k$;L_N@U8ml{!< z_zjKX|9Bt>#E0UY<R$$3CPWG0j|QQ^<8KCnbL=ZDWhi(4Po&JrP1$#f+xy9)oCqFcL zg?gS|&k{SE-gj%hl{A3zA*(b$tDW!!&+AqH4GIPrHPi6%z1gGu@1@0cPNaU;0~ac_ z9u0xOJ;jm95|olyxUwK0ZPcz>Yu|9=99bw9r+m0a!Rw8$ARmL*hnD|V1oytcb6|z*sZI>1cjm}si0Y0?-opg{{VHodbbH%TsGfZ4 z!1>J3bCus(5d@UmTJlBuvOG@q%<`1Qqam6ye8md=Wk~LSsNpP9Mp7Ytn9(x`Ob-u# zdF;dEe9|T;@^ilLZf~YB8_2H}GXF6+Ugn=dA!ka4uhFBe&Ek4?3+T0J60+KBsD=09 zTdUZyEM|MAy`%(u*;zNkoN@AL64EdXx%^YHdPB8}b>mnNqT>NkqR(e6@5dX!>DT^9 zCr2A%^Zxkw7zN{{sKtwaPv5J6;wwUdUPxy2dTSI;X*VS+F}A9)axV*(GNkurFqmo& z6IMBD6!sfuxj(}$ZfHb9T+`A&H)L%}tM<7|EO|9GOgHznaTw9eQ-Vwa$m`7q{pt1- zfgZQR0gZarX_aU7PsX$V7r0j?e39m5ZnAjjS@oqqwT9W?^AJfhy}>&g-&=-zbGkqrg{3k)#3JI5+uWV7!arMf&E@rtEB6TC_h>MS84wD<#hMXP=3CAd^L>ohlxcYpPE_sua4zJ#vY}wvxG=>zrX({qby1=7G*P?~yrAwB6S(5gk5xe3eZK z1Y9Og&h=zCsalyir*o?~B!Y($OSMeUf0%3$nj;*v=y9Ju_~MJJs}pebkjrdOg^@N$ zknEMMd(OMx|6O~x==8TzYTe)|kaN^C#n)@gjR2NIB@jCxiX{rcb~%qDnLpgJTdy25 zJP%BvReJZuSKIJLTv^DEejF$$UZ*gr>bY9I%>g7d>=6SWcib39^Dl(SK9-@VN;@$N zTMWBHU?BN1{&eqFFrou%(HVAj>EomP6U)WYTxQVPW85Z>#j zQ4&*!LJ!s$=TXW$jSSIe+4-cz8Fmg6LTN#eY@YUp8t#( z3x3G}`De7LiRL=}oUy(vnU=S&R8^|_Hp}U@pW0F_+sZW2Wd5u)&y-?6nH-8siL#D9 zD5~&jCuWI3&(0m_6l$(~6tgixT4nBw3F%V(P&RP_$9UQ0`7LN?Ez-|Y7|~>V(NLOf zJ|8jAov&Oj^EbKIcoM;M`GdgH>SdIir&vyjQ&dwhN?bjE(yqMtfw%A0mM_BbI}Ku7 zBvbB?RTKRCjg|=HMRCK2Uu(3})Y$w^r07ckaUte*q;4k?l$69Qd=U{|WjX5rRR1Vi z#cQ+UhKdvv{{>5qMK+JtFXWC^2Q!&K?~oMc8>PfCj>!W>U786T71I0Oo&prO}Ste0Ij&gOCJP@l`bN}b`VI?1oo=~r{u#F!M z`lGi1q-zPBG5^ooW4TD(HZ0%k=RK5b%M8Sj7Hwcspw{S_e10C2!DynRW*I#^pzxy?_{a_?4~xM+u;1X80u)}udoJt^Xa-uoli-QB>=5BS?hi=wzSkD;ERDq z{`$`#f{EBZ)>8 zL>QTfWh0CvynrvI82D$jCraJjKDX{IdMLX_L_{dIoYoQ`NsbctIRJ~5;ydl8#K9s) z?IOBDTBV#NPVx`V?u6jT9!z|Hm2lW}zX3l!l>`wJFxaU!p@!Oh0%HeiP&!8IaPw-@ zP5gVjyAzO{L5KAVM6kL%?GQ^ow8 zlS@*naD1(5kH3tpp6<3&`Jm6Tq2!6`!6a`9M>f$ZqJ*!rWJW1LEs-nC*QJU2?Ct=~ zt%T$oml&i%*g|upI+t(b%2Ub{a_WO+iG8C8qhQ8whODtQh?R(Lk#M;=-L(0OyP;Z? zJqp*6x^m{qU3|RW>kc2^?8$KVY!iLnytAL>Qkloh%nZ~bvpX&2CJj#eE+9a2{?t}T zcQH3N|N3&J+$LHLQB%UWRxi7#w&{(}l+%l>>(q_%F60{(`lYhn*CiYkgI*Zy<7|1@Vu(zhQtz% z<*O$U2EKxA6BT2na!~b_q4HcHY6^#NAJ!DeqeYWQzuyLJM&6qiQcsq1_%Z)PuHtS| zwHBjV#nyiH9M4L+Ebz*aa@oZSc@bhuTDgDH{Rx4DqMjnGM5bi(-ZZS!8yz-%iK0<7UARG6d*e!kNpvD!&F!)`Uw?SbFrSv59v&e-Q(<|w*>EN8{e}_h zC zUDRTsOxm|W3cFZE=_zM@qN)3}mN_)Fjod+>?9>#{D(x8Mq<8~cppZ4UU=nV%Oq=zw z_hhL;uq-4%!40^Q-muu>T4;Hwb^;Ud`A*5Q^dI!w;_H+0Vf}6fjS6yrLTlFBF=!Rw zqw8dU0``4S)rZyZU9GNu<0Mp@vrCnWt@;%=Fy+{7%RSTj8xg{PzMM0RfyV%xGyUKo z6~p$t8HRgn6IXp*r%@BN=dhSF-dc)zZH55Zd@@o*w&3b|G&I@ieumWu)wDToCtGl) zolo~`ct1yd0o+GgHsRzDudh90Cfe@s+q+vh#u{Gjik1`SQp7QyHbH!7s7#h!ZhhUN zN57(SS-}zzp`S}9ZhhO_|8#chsF@*?x?5cD_nmHqj#vlJo;25>Uf?K zF^C6{M$E$&2{!zUU%y>xI++KUaS959&7ygO-pJsfAbST*+q0+&Cs#5NgV8$01;T5H zQ(xD!>@O+j>PmNRS2V2(#H5XjAYsOmYjVWO0XdBhjeWxayjobfAn0} zQ8d2ueKcy-KtLHC1l}Bzm9^YcT?@B{*!nVX#vZit{~J~c_9gSW_D0hS_|ChYN7AZC zClN&+C=#mRF!3yFSSNply;enld_wM?r&ERsdQ0pMUOJy->+#426VlxhUvzO~y;;w5P~Txe@+V{E$K0M_ts$uX6g8p7D}m}4>;2qGAuT$s-PIOSez>iUTJKngcv~;dkAU*kWXvlyZU!TuZe)7B41xp!a zRqy{s6OVpIob^0w9BSVDQI69*N9z$+|HN`bQdhs`#f=R&f!!aJzNCSXEzoiN`xzi( z&r_z=tgE0YvHe?9+=MO%9dKG|^~IQXh8JS$V+nVll$f2(P%vcBZt|GxamZGrs^JH> z* z4xm;lz%Q(}T9gGm=pRi03V+&t-X2g|u)2|8iK$LX?*D1yDGeJE;#8GWtz3$Rsg}qi z+uoC3vfDR=epTNx9oQ4aH|lDtn#bR&B?(#L2TN}tC^1*tvlzVs6V`t}p=WHCV}H~t z^Cs;M2pz0|1)cDHP2_iTAui_D9`B$jg!4S@Oy%)(`8Q|9xNUN4;bS;tUM3cgoIt0UK+h?=!+IpmtWl6++^GV z0n1o>`4-iN@)^3$0lLJ)FL2!tk1i%Jtn4<+VU%kYM*@=T{yEq0=`uRi!U<`~$`4~r zkT7XA^0ASXN!;dtPu!~%B>)$CI{*ijS4>)(S}j8~Ux^--7cL3vic0i}!u@wBz^y=i z1>{^|wFmIAUt_x^KlXi$bE7vAxMh6t#P+UL2%C3CJt~IrniY7F5khUfh8mBUMyE-c z3?AR<B7y1u;g)=s`-fDyC>gX$$i z>a4dhao~e|XKTJ}oHg6Uu3K9_Vya}OK<$33^n0wyLme4TA96R|%3h%}y|1sF++Lhn znHgE7?XNG(2(JgX0LWE*GFvH6F}oLTdz!sf~^A^@o5xVc{) ze0N%oL#s5N=PY)gol>Aw`J2o7aKW9_jm`A#htL&l)Ae*2ZL}%S#eP?&7cjRl5U_K# z)<*9+pl-d==6`Nc9M83nXVnX?@4@%qM$#xGV4J;wyw2wf;oT85W4t$O|CugqHCIFZ z{(YA8{>&HtGp8%i2^1Z6-d~PpP^(v4l4Z`h)qYu*!PpZzv4lcJbF&Gl4G!J7E_er)qViEeMv zSMkrZugsKRW(FmGHq)iGHMq#S9AAkUAF-_(aMZzm_!fM0@Mfbg{0Rm@AU8z8G%8Dq zaB-$EKQBAp2$c_@wN#xUq{YhF=XGa9ByMY^iyJpBJ^~4nY2P{rGzWROjbR(~ox!Ad z0uHX{eD2q5`@Hr$r+V%wZ_k`g#=57bri{=UMLV;;5%7&pHkiB^`C0CnkZ$Uc%wT8L zi`(DW4>DrTy7b(QZr^W)2bhK`o`C#>`HQ7=8a3Dby0Z(u%PsKLGaoDY6h#x2+n?mP z_=R|+2%H}0>u-wp!4y;gUIkna+8($2qe#i}#7|t7*Dpi@Gt4X}tJvpRg75VAmQsuJ zHRU~$?^=5W?$k=l0Lw&P2tV@-PCf(#*hpjen5ejT1{J&29pZyilw$uY#A{EW`IQit z1PpIH3q_UoVzD&K?+E=;xFPTTLl?Z!i)^Td9vUKD6LUntxN_^-yRGLYEwT*>V zLRD$&mS-s8{oZtVVpKab_P;{hY>Sk9FzLAj+#8`TbP72-0UQ0GrdZ5Q6pjHCeb(UJ z{>791nTGoTb-REnU>~19bY80DSlF3LO)~w+PTBy&Meo(DcU*LW?ZeOasZu4LpJt0i z!lw5N>Z>ech#C0@Wz<2W=N>93lhgGqQeIOA!~@>Zj%vXV@MD@eR&8_Z}6y<=?bPz+|&4EFEySac8qu z3b2tsM%?!1DdFKPz~dzTlJOHtohzMs)!x~p*6j!ksoR^^Zp*s9w&;4m9WhZASz{>3pHFQ_%Y`Yo%+$Lqc!`B!2W+ocLRYP7h( zYvK+@9qa&aS0!UO1V#TC9i8e+az^hvwTgGS+Igo}Zs4Xf*(xOQw)S~6m-qz@R>9*8 z4@C;dYwo9M&yd{4a~@G-B@hy9oK4*AjYbq?DN z$0<@{I>0wJvcjvsIiP7Omj-MmoKc`+>&m?ub=j@5kj(pP#JD}ehRh^eBa;dk)`(?E z!plMDx5TGslbZV$CErFV6>APtlM(RB&F~6mD@=!PgJ(Trk6sWoRH&AI^@Sg9e}hSJ z$Xy*AIEH?bTG8mQesI)?kb3+viRh3-RvUEkY(vOK3HiTSfQWei2r%~#2^);7o)i-?fHymk(9L3QNK4ZQxF@upONVj2BtqLqy1y#W1a4PZ@z04o42tV;>ctjGr;BH`);=GdzKTLvJFaHpd z@Q7oEwJU2Cf+557aHd3z%e|+S<^1_!TDyb?{1%(VX0mYc2wt$QZxBH~0K5u+j8ak! z_3f&+t=s)yP{l^>N^Tt643s%8-aRTN*AzVs&y@7VWl6$^yv`0?;AyTp+`+GMc~@es zJFhV`x4Y=Fo~zVerDUI$qFS?MU^PO|oyfJ+m;A`QXp^9WP4wXu=^B4YEw`3m40Q$0 ztQdOjRMY1#Uc}r}^o-{#X4=nl*~eDc<}2k&O00|H7~Q`&`r8o%%C^ac25cq3nAdq{ za3|Odfam#>1yUC-GZiL^IWjF`@*WG-R`)=iQJ^drXn&0J7?&liUP0Mv=Ke3#zX7Y>EdNOJ;j|EF{C)3V_@gp0tum*d*zHxfk)zRzh^LlVH9UD z=3sZ$dX9shh2vCm!Y}_R1LgqPqdD!O&zpKx!-Yg@n_Fe15lfBagP} z%Ccc=IX>U}d?aXbHRS9SUg3@|7Cwp{OOk`@)cbeU6`k%+lHt6m&TfG%YbS%Hc4 zSaG@F3TS!b6S8WZy2}VGYiw>N!c&^d%w*#(eM9QpXMpA>=cmmvp2cu zmd(Pm-kVTu05fE4WGYE~b)p6eNeKV-(t3-)X214_)yZjcnPTVxp`72ggF3h`Uff@7 zgiiY1H%1ps1OXTD>e2GwfBxFTqV0UOyJ-2rO7qjr-5f<$~S?|Ge zUvIxY^O17zw5X;ePrSV8EnaZG7pUAloAetiB%_=pz8QI47d~x!_xh8xON~ck9f+R! zo-9$BrO(h&X{#06()b)V?N-)O{lu&iHp(5PLMTtdL`-G zhf%m8GNQRVWx|b7Ax^%(R%NrvQ}#k{dSCESKo3;N9(!)|e?7cDuxhI)S+A^#l{xoD zQ@eM&Q2kz7;YE3mPt)N6;@mueYpn6}{QW_>;$TCljd!Df*H$=bKDa?19a@a>lhQD*}+nxdHz z7L^LNwG5GHgG)hay71<#T)&sJbqaBZYcBFp}T$vR;38<(E2D%ldR zH)&s zd|k8j5pF+jZ~Lf<@pK2VK^Ep>#%(3e!Do2ow=+^vqqG*#W0i+L`F76V|0Eys-X9V?Ir;Kdf6ltTem7I6$k&i7(RRT6CXG z0z=kb>l5+JT?i2kK~!X8+Q)dM4`nMYr|8^DYhfFvRd4OR%3P%?yR2{!=_5;Rn#WIt zWw@AIJa*y;TR5 zH6Ty9SRwZ<5@hY`+ehCWPGobPj0}(>{!om@?6_wL`m*+#8}#l?8L{suV71O&Nmq zn?HAnjoy)C*%g=&>+j$^0O~Xu*Z}f+ve{cands^irG(WWOqW(A)fFqEdk@`@!dydM zr4m=3Pu1>-jdOfC%p_D=Ka78>FPTipX_iU0%A_0W+1)*QUH* z{jE)npNFzlrY5p&9y5^(U7YH{(uZ>I*n~l`- zjFohoUN$$$9GQq-*@hOe5=r_WaXr56l2j(q2eWMJ$jw~+<>J`%!Y_zeDx+R4n&liB z@op&p(thSjkHf?v`A8>J70l;)OWhD4wYZz^ULX-%;idjVzaJ`(KJ-gihBFQDl13Df z1!B zajweZMTm&6j|fGE3PB)@3jcb{H+0vcyU9ri~FgH?h z4TDJ96^E{nMd+vWGn|=5;yv@+5}l}O)ep5Ad9s$)7YVs*{dGxhOmoZ4e&Sy3ua1d? zk#_0S-<%;z^i`1i5vqTdlPK+L)6rqy!uJaNd@gNc!vYk_QyD9~jPGF-7~o-Rn0`q@ z@@qr)`r=Fx$>M(WSeN`aogO?RChiB%2ND|LuL5CJM`F#jsKu?L(JrrW zr2oSGCDu0QG^ni-3#V%Cpf7sqD;o=@&QpT5SrH+nipaL>5GyuX<}l2zeb5ikcNNvV`=2WpTbt z)HU6yLh#*E1$<;7sy{}W*O2}hC-g-$3h|X7`YYN_=NYDo_M@#|~^1WX3~fzP+$)#(|<@~e@G;DeKC>G=%}^W%Xa7HS8Y?!yZkcq?%@ z&&g3n=`hVX zQWaHYZyE7)C3OQycO_?bx*$6OeDN*4{;eLcd1XX|*q;=o>A-<_Vo4y)O?Fn~RMY>5 zJ+1Gfa91d^tHa1-z)8>`1M{Abe}qQ@>}Pcri1|f=U%7Db~}vmc!R z(*8>IHj6-m0Qcu>%u~)6uHR|~VjC%s-8=1t24#(f?XqBE1u$_ZxdoxR@2$XHJX|i_ zg`Mkx*HE|zgFTS+`%U`vQjeE0Pj)_+PNifCh`V-hdJb@e1KtYxH5nUtKSJG?bxm3B zthi;a^B7xKv@oWUm|~}iP_egvy4F|pYM5<%9jN@u#Iz#FkX}14HjSfT?&}d&qAp{x z)2W{*WGqw;(a?}}SSa-~%C=xdfjU%o7JAPD`Pi0YuJYdp z?R(%{u5HLMtq@YR#7c`zpn7=y=N40Rx~$+~;&%jn-rRO_2jU2OqGt62dfUR|}?xkG=rAX7YUr|9?QZf*v7tWQW z4pIr9SuHlD3m&hC--aIq`3KkTob*0^*;V{Rqux^Bb1OQ{kkA6KI!iWbWJ@RU0slX^ zD1bXAhXM%_WaOj!3CfFq_4An^ofq-!dqH2dxe4e@%lM4)0#;7cNK+Bwglu0`MAYGKvhXFa4AAM73?JM>ma~0J! zse5`G!JAR@DrzzkZ}y#_8oLIXtN4G_Zd(yhb^Yh=3^oG`9{bZNSMBMA@ZG=|S)+j` zJj2e8xa%o{T+LAyy*eSg`4FJCDgr_B0m$bcE$WG)rB|uAoGM)%GX&Em5F+F8?I_=% zCsGy-up%&!QgRBMCND-COd|4&YTVkoLUJWVn1QS-vwHsz050RDT%{Wqdd9;OLmecZ zr|9E;MbA%<8)K$eC3jQti%_>zmJC{vSRpv^)V{#r|7HPdO1Z`op3y^SQ;vh z{3godHL8mmz_cw8EoghlmXK5*O0nw-PGy<7;7ddcqsFgQusomqu^hF=Jeo;_ zEGtWVmgkY9ohyC@_*ubz4wRB3!+kp{g<`Qd`unEi1q^@n?jPUwK_0zrZCN(SADy=K zpi+!`ni49UfG8Iejx4qMQg!{&`e4lAi8OKc4Nsxw?E8i5VB(qKl%*fO0ojAGV$Z8# zXOmTyEaso7SV8YapM_KLP7N^7%#PvtMm#Npa?HKg>Q)EskdkBg?RJ(| zmi8aEnO9tHmnZpFIVz=}{lB)4Y^^OV{{`8biMPE9Cu;I$0)!_wb5Bz^Z7CTT7%H@Q zr9@RB14ySoqZOwQKOT@%Q~M)8nms>BN=o|86l>N;Z+|K#%wpv)9W!l6_|(YBzM!dV zl`23xA6AOUzv=LyjDLEin;b*1MZDDMw+aS|i4YrhiBjeDE6Gk4b()*$VD;z_ni^H9 zOWH*34ymG7OllD*l71!JSm9N+m2SAi*X6Gxdy!(;1v0Zp;g4Wnzh3D()u-I4?_y}bM zppR#BLBtrw_?X31paD^p`TTp^2`f798y@!#04abktv)7MWzVDV(+?-ggMw=rwhnUI z->9&n6Z%xKUsOd1<=d$)yfduF;Qlj%?zgd^F~ovi@o`H#Gr<1Sp+3av7x~4B^NVho zRmd92(Z!QH+mp>fe&-!FBioV4T!59uvXYnTc}zL&v}I{{pNH#P@>zl^Ik`xZstUw&u!->L zM{+&O2YZ$3le>i$PwD#ig_K{eRBvfU+(!pT4J$0>d!{8ej)1OVx{w883hcG(ZWt9g z_hSeq=bdavqxVS;#*Oa3BPrN|BzdaqGppgMRh&t?yMv$T(&c8{jut}|<3PKBK9DHk zfztrTnK>xS_lRd(-`TBy01y=^DX5?)J0(wIv~920Iox2HALA8G`A^R{9QWqNvsR-X z;?CK%-Olzw2H8m7yaC{tr70aiXahTk-L1z>NQD`oopLrScPB>2M<}rHPggJ)u|Z20 zileU|k9#oibB)j1h>PrE5sF*qaBW=Kg2c{d+kvyoqdwpcSgJ2nf3f{DI*q9;q|0Jgyr_L=cVZ)=Lct9pqU}%b6aa}@g|o6aYxaI zP!eU38QW6)B;9bA!yF%g%OFgp1?&0Bm6wP2#pZD%89zk=Z(;{dz=Z$)(srS1I3-kjJ{`UfxI5j?pIZTx zWe#Kz5U!-{zAnVIHZW`DzO+#P@L7FKkHC^ZuGH^D4pV0hI}`ss{n|Y>LV$Ji&Czp( z7N%Dgd(m@l!+pWfXm&2bgil>wuk{hD_D)JgmHLqYgnG9W!G4q?57?!wAfhnzxeQqW zwQ93bE)X+gItgsufI_}rHv$;PSB9pXjy5rQTspOpg51!~tnGY-($-GW?Jj-j;vp;! zG`J7j4CwQ0KxqFq|BQbGRlZ{W`CsJap}XVF8&IFmTvxu)#87MQ#1Ck$*St3a@Wvq& zW_Vj?Zyd+Fc@YO4pd`-cdJwXcX4sXB)D*KF=w~u$8!FY6>qKcTYGA>|(A$IKP8YfU zwOoGwOXEX8wcZ`}PsSzwX#&~3_8?GNr4$id-z48Ee(pdn3C$8eMEepnALQnzfPrl43abX=fHxNvfF1Ir%d!rL)n5Bvk7ioFp1_~AWRr2_8MBnQ)- zoEWuDxtcp5k;a4owr#W^zLO13ZpMZiQ+SnyC=} zw+U98jGCTX*($hCvlx8U(~Dua{JRNro?Qfd%DiQh$#~wlRm1H;{@;ZMFdx_*h}c)= zLGH;di|s_OfEF2&16b5SC7sM-+U5$fo0G3$r+~d>R!`Na<@D@R1QC4o%D2iC&aZHA z&Zc9-9=}ZB_Te<`aHvo?zrl=EK6Y{Qet zlw35qcDfj5&%0KD(|&hkc@VzCLix3udbz4dnNL|`kH~;@gPqdsx_Xc2$;A((GS^&IY;-9N{kj1!7or%hMRt?Z1sA;Q3)5uH!H&?R5?7 ziG&cB(*=wj`lL|qPu2DwUO?a9(9122Ex{O}e8AEY8%CC=kjrz^f1jHUd>!Kb;h8XS zNisshekYu1jq7 zhPq1dvLcq+KRBu!HGssu56lh~qiZ0}k9iSe14>L#y?#C-UaD~DT`WmglG*Tp8JWQkQ$cOJum zLt96XjSjU;GD_ab|C|mM1c-4wUh@PCe99aOB~W>8$>(#KOyoy3+h;0wx3V$-L^;TF zCirk0tXc(WVE5qk1YQ+-U=HHtty=`QuRPBk=f0`I>RY(gPX~+bh&$PI{!HaR_W8IK zW+1i;ULRSKH;QWwKCzxyHZfNU#bxxe||JIJ?gGHkp*-a5%W1gT3%b6kWFj5&Tv&2Hzdw&ec^OUOx5{j{JcM`1b6FKfd$aJ>nN1At5;AH&5cWXW?tt`7q zAJ38A?nVhDgPqOYrQz{da+-BHNhPb9s#yd@ZVInWY~zfogSNkRWSF& zz{Rn&F!rwO9xQR#hmsXsZ9w!-r4uiXQy zTSj&{LC0*DeYU=-N26Hq;jYu45sO4zc_dFL0Pr9YaUTcWd!sGBoRNx(3~z5O1SBh% z4qoS5HIX@MS4k3#oMYh8a{?MFEM4RoBGw%(0IiwtUg`T~WRN=?a5~&LH8os;|JJYx zv{i+$R^P_{F2Brb)k9C_JZhN|uSvy)27`d>5_@9pIHlncq zN~i-J5+|~K(PpaD9-d_?>fL#N@Yv&?u0EUlGkacat3?2@=)OP2eUa|ydqLZ&I8#nQ z;J|Hi6`$N}B%f6!r&_qyrCa0&`;Gmimf~-Llw7O+>F;Cc2pg`l=?+G8zfZq;&MP&1 zBuqsAA)nMmt!pbn2fXf9upNR9>>~i@y9#C3Rw2`^Emf`<;w1o=$qT6dKNpbVadIpc z;k&XwmdZPw$}Bl&-PTDJ`lMl{EZ7SAChLze0E_^~ehW0wMn^~OdQ0AU$MUai)S_*<%-Gm>W%k2wg^VK(%)usvkidzDTu(?=(boH4 zeBR(`@Xk%#Y5|ADj4)_SwXmgz>ytQo4Nju$%9}SXE73z2JNS zLMQnh*F+T**fif?&93s@PHn=5on6m8RsxEfh{5VPyJ@>IkU=J@sp+_{2=2o)%2jmp z(uDw03EcAdatanS$Py05NAhS6Zl(m%$Y-4bwxt-ljf>js=>uCN_A-}V{_Ok1^eg$N zsP@k+8pz{AXi4A~e|adU>D>VCk{$t-9go{pKB6hrb(>$dZYSpGAt)vh<;@E$p*0zQ zF9U%=Zt_5l2%lmx7saW1a&dRLL{v{zAC$&fn(2PI`THX(2Vw2c!vTK&`Ymba*C5#HBhtb=mPU_D<22pXm>?4BWJza9k< zpQVbu8zAzr4s5kJeH%$9n+A5&fiB5mK8Suqg(v!c() z$PZ}7cazXpw((+Ebmpq^n6ptoB8gn;w5w|q$Y-ocEWD*@tVfd-#B$XF@{e1@A*`Lm zRdn=)LIsuEfe?ox6fek#wPR!AX#IFO{0|6W_?@v=y%<7;B{{I!$Xn-n0gRvGIFGvr z4^7;A`#Cff1Of#P=3&pC?=QOZF9XdNn18OjOjD#U&n4tiL1^Ab!|e)f!@JATH7jV! zP@!229vUzF0Avl{>6?RXSY1+$$Je`Ht&gC6Lioz0XU_9clETo~*fn0pPn$tOSTAVO zS;lh99!I3>)!sz5l;Ukg`Tq{)vR4!AtTdiD%_ePng8}Er8wl3PErtADz5q}^zjJi~(pwk0lYyx|q6$If2x@e~$w2Ilb`h(# zSSHQI_EY1(oZ|jE8*{3?4m7g3)k2lxp$5M(RaWpNdh}B8FMHSi=oYQkOW`c^zbYn3 zsqvP18}dW?FY*9p-0=@^sGB}-PG4{MHL2mA>8MQ4StTwRdN~K7N#wLO1d-Rk2$*5? zDcBg)1L1}X2hcv24>mFZKA|_d*x1^n5nKZ+Eyqu))Ghg3_NMmd592@%IO~_Q+ixow z0OFH=bx&_H3@9K@#2WDZ3tO+(8BTMsP#f(nvmSn~s0}pv$2dK3N!y|F!JPUVU~E>% zQ=vXx*|z-&dh48_EkL}aoqPgGw2!_ufEE=>`>7|_uZgJIgZvR1|kU^ocw zHtw@-S-jia$Oq%KbWc3)rz^JG{9gbn_6xv7j7AMXtw2V8kTH+6^)i$QA}^_so|FvC zgY>w3Hw|V=WkE7^ha$syIre3j6>@|7vgX~BC$`ZEpJfuFA4NxVDHFExg}sc=M^<n)8#rxWf1x~E;+c<%=>0ME_>sP!*gN)P&zt<-MMWxJ{trH z$UFH(DzJ+g&`LJmPFN0NIdPp{Ym=~?PFQMgU&k=0Yk`GrXD>x%BiMP@Dwkt4+=f9; zCZ(9$D%PazE%#x_CCK#_A!&eq^1klP&?%CuWhpe9Xz-a>ykq))>IPQNfQmuOupXMi0sf+0r2U%!|1YLR=R(||8M zT5SV!Xz1#MIxd5nM!ix$=)5BtG=IO`7)}=f!;gsqlhrIKB1W}_wH;c|YtZ_o;t)HM*4@Hx%y9Ghp{-5aBK0t?zxE_fh#!-cPf=C=|hOQiE+oPpV(Y$3J z(LPt7vV1E$l19Q)9o&0X|4h=mt)kE@A6c2v8vaSj@a4hB$xQadjX7%Mvv(8Gyqn<^ zQU{uh(5*t)h*`NZ|ef*A?8Y3lax3x||K|##x5Qw?(=Xx~z%jk>1fB*@&D)8x# zly{7YEI~hGn(;GwUM~IEypi+aiDi}G#fW7DUW$MkfXOj>541xqjpkcpr=dG`uj6624l23&&-ZE0&KU1F~*25 z^a>2mQ{&}j)0~d~5Z0-kxO&q7x;y(&{&u#~WIk694vLpF+`tfkupp0oe%9}lN&-8) zb9fk~Rm)+jmSKyRV{a6_=TXv3d2o9}=n_AQd^spa5Kfd#lF?cNZ0mMJ-m;P%Rvi@v zY+Jq!u+_#Yi%dUB+TyHtUyJE6eul6Q>`2aui?J@`F9 zW;94U-knOFFaN@i2pSW4tw%0n08V$Ywk+JoR0Q@Xpml2BVQ&+q%*KOBzX^>WYQoPE|>d(O|K0UmE) zJA90C=Jo;v4oa}IY^V$1{@He!F9_#rX*6n(U`#~!NJsp%OI0voV-!2fD%v%+4ZUQ@ zVAcHv&U{ho{~X|L?W>HACZABK|8)rFoK8Q9`Av%o77iGIf8<8 z1PoAdxo*dgx@Aql&h8>m0czXZ4F)rS6muLP)$v3DHK=#zNA*YTMr)utu61s_S%p8O z1A5?o)_N*f^^o!y(vhW5-k_=cRj8YkgHJ@Ru!sV%GbOCN>jC3LFayNeC4c~gs z`>={ibj#iRiV^L#@GE%4m0x9s)7QumX;?0b^s9`({AL)Oxg_Hh|1Tvv) znFzPAQeLeFrAEHH@N4lge~AXMA>Lof{?P`g6wdMyhzP`f5&~gHonYwsLBM5iIFOr$ z>UQ%T8V-Sknb=no?x7c(R@L@ciouiFnV=byk;IP8@)xXgpK$?vzP|56|HeeS`W3aC zl#VxrpJ=-l^oKMnQT(0Fg3K>#Sr4Sam~Z5);6yZ8Aa4a1Yu1Lq@;H?@Ja;08*JC}9 ze4zmh8Tf3LzOp8ep6?^X-he)>3>5WC2si;q#qdq9vcH7rXu!_D&!g3dWY&PYt6yI8 zCiSFi)ie}RRQKL;S=4;5k)XNXVb<-?7D>oA0_u_RXD`OWB z%?v@uxO{^Oe~3T3TL9jd^;UdHeQaLdJ9muuT3u3@h}SXBXiP3qx^7LnvTseLHUncR z%WA1U4C1aQ+y~;}eQeA6xDZ-}RbQ06>*WM($^#e29knl3t->;9OT;S21bHoV^xAM8 zXrV~Fi6CgN&QYbCZNOzm@t4E8>9N7$mBmi8-5@=tH`5iOW$OpbO3saFkt*H^8G@2CzsRCVoDEBp15~Zvaj3@a2m0ckN#hl9 z)FlQf6_~xmu~i=f;MnSA|J~j zC?iRsn16qMjoq7;ADbSl#LthY_k&iY&*??}c{o<4U~V>BNc7#yQY|#s(qpLah%LS* zEE|f}1#gWcI~S^lvu`Y&%}iId9pd|WLQ^cfh2$4ZvZUdLCYn$%@aTdsq3FOx%e z0LRiwI4c=zsG#P@0E!hDkRfGS2o^pk)eYrucIt zdznkF5IDeEILhz+KV5*-n@FSV@EY$J$~hK>vdf=JYo>doe{3jncRt8R7H@1slMhEm zCcjngecT`yIFNk3xj}CJUJqgj=h$CZ(FCX_1%{*FI9!3`l>TM1fO)oSv(NQV8ZQK! zdcO9E)AsmR?T^DEVAzWXuP!+-)}6mRa#mWZaytXsd)1GeZhI4dfh*-&VK_@J3J)x9 zDXebP;35bJ*hzC$jxPkS~mls%hG)P3@`qz zv%7C>mIVaL11MgxX9TaHiYWyoBj)#z1}bEWk3LGzt53{Q9Y17$EMS>&uGkK&|P zq7C&Ba>-xetFr#*`XyJu?9A`i#I{e)-8p7gr+ z@7PqB0nG!8?cb3l^b%@pNHfC^cpMm2w!WLB-geZ)7?mVy%nKn*Y%f$%ps@637@C*g zptpCth+WQ5;gdjD=q=eumNZ8zlPuYs7X42(TM^*HZuPj*8G(pJN3WjM(}}vYTrIWD ziudf*8g9Ke2chuDd(%G0ejZ8e)^BRm!2aH9$M?=DKmbq%vn0865N)*B;7n?9TaT?x z4+JwHFnzqFl>g~li3u>^r+G04&5a=t4PkVS z>Mp5Rx}&p=ak?aqyX(%YLY30nIO0WhOKgb*cR-6m@Gv@Tx_hIu*xZllES&J``|y9f)523d5ABp2*W>6DpH_LH7d^ z9O(0De1*HYisu{~>%W892mxew5Ej)ipQ;Aweqsflq2~aI(prz2fgX zvM@I|%WHcajv!1qRie@A+B>GAO_;-;-voNyiQ5~GgWLIr#eK^f>)F%06O1o&o(~5u zmD~41L^fb9B479_8$^t!$AFMl2}v32sjBih&?nelm@7SRa3==2R3;LNWxuMboQAB59a70P}~;%|>sOVJ8;tXF)6NrTonG zz86A|zY|z;*H3?Qu>lla3a=A&o*GEC1dKH&uuU~tsF5pkpsr`}R>Y|H)HtXs%Lw8d zqDRx6*H%z=ssu;s!Epc|6WRCm)a?MdR{4j>Ji>IiS~>4e>H41DsCWtt;{@RqJn3P_ zjRc6qpAUc033S2g4k{?WH)f8Cip%`X6I&q8du?s4qb4h<0w|I|O9+CtV+)Kg64{*0 z;SlGV+|CxjJ`r#TTLU+d)L_xqaqVJ$w}!ipBNu= zldPLaE&K0pPP63{0GkB3x+X0iWn^S%D&OZX1AG_3`C9+7=T#>d0`pC6_j=x(KI6(b z&HM%7@jAvjoU%&A?I>EM(9Z8UdPbt(`21C- zBR*PWsqO-R5`M>>&&%EqVMKh&1&Y0K+Bh@dJeSe+espH$@}B}hQyb~LM{Y_QakrdW-YD4m;%t`@(!AnM+s zlatGnQ5|12%|Il-u^{$7Hamx*g7$C-hSsQ*p$}UWwThBhMZG=CZ}H8@DGpO^#Gc&w zTS}}N+SWw2-e$(v2Sp~fV|r8aI0|vH0w+Ll4qiRI;bDD4L4c8=R&A-b)TCzS|Ll-r zr>EC3^djPQ%meaHGuG#f3#0xg_tko+p_%oO#Hu2g5Xb!8f>q^m zi@gI=!+2FeT1oA)o4`-JhEXYv2lOz3i}Q6^evrnnRXqRbb+?EL0_K(*j1a+ZW0eVd zjIJ>z!q5+k<;XtYtA4Xh^qEuJScVbB~J`q{12it;U_$SvC{C=|jajW|*m?2x3uE&{U8%4KC)jeH z#eY8^(gPO_Vky76EbD`doJ-!p7nx)E&3LDKeT{QHT4`*LIR(D@CBerZnNo{@Wj~>o z6JYF4P4;2XiX4xIcs3kiE<+plf;zMIkF^x4pL-}Cym2-dpx>P(8i7-7-L zPXAa$9H;uMH}sY!)`+<~v{etuJ?VMQ(;X%Q?_}^Ik8ovGn zyaxQ42#09A+x`^24jI4sEyk$PVQJMCX4zOeU#rnVyjk;jhyMdZfbSDd3PMDUOiiXy z)WF&#rDxquH-FNnUiIRepa-vk6Vll`9UB&nWjyNADwc$@$aeEvom$IbIHU)}|2vpA zsX9izFvk!Cmv8L{11i*9G3l)Ndfmc2AHLOtxMyh%!_&6PS*B5Et%9IQxnX@P_pW>9 zVn-*ms#TewtV$CA`26oh!aSA;KVxvwz+Z+C;d~j$O(8RA`@tS2QvbW+3(SSKoi<6E zpOwA1@`C?60|98Mi4bM1QZX17p06=3sXByHmfK~LoBzb_&mSttPV$qfO&ht$t2TJU>kb9%Hg=gk>cbvgE<=vDfBL_2OI9XCXlHK^*q<}~?*~CT z)(JHW#ibzd(nv*cxrzNdom+l z+p7ZTvlQnfP~5U@ZtwngQZ?TRf*Zr0?EU-q4v+Ek>n-rZ$y^u>alq4m)h@6#lxYm! zR{s9~V;j|^#onl|Vo`5!}tyvF1 zgREQwap-q^qb4U~M&vGp$eyPY@8>kj?J;}Td2Cw014icM)>^Q+<+TY0*odh|lW)!Z z_%q{w-%6fBH&u$f9U8KsP2_$2IG#Hkf^)+Zs4cL_kJqJj#ajl%3V`4uc^VHss3sSv zv4HP)^Xe`$BLhj=7)7u|C|`$C%*kq@?s-}xj&J?$jy000?c*i*-LLHL&gV`8h}x&v zBd~RRbvXNBFHX_!NIMLyuRY+AbrI9AQUvncC^u+97rv6W45Af3CDj%IZJN`x<{!a^ zo?|`EHi+-S5ODEkJrO^H4f_D8K&6%H_W2`7LD=H2yMtPOo}E87EjSuwx11@c^S+Ai~jFD(`H2d;ojV8}ANV3O#^MXxYsN z6r?7MI5@tDNhB9{sz?0a5wMybVy%&2Biu{gfiD6f!C_nD16LAP_;Ze#KWZcXvPa?n zW&zcAizY+l{FAjVpmod4u>A_(1{c6_dh>X9Q}K`tg%{dd3a8w20uf#rO9pAp`<{m;2Gnj z;C~668OCaWbNYYM`Kcf9)w{(=1^?gEJGhOlZbl>e-N>%hDEs=syFV>i7>;K4bUYDD zj^R>@?-l&B6$Y#d>A=7RQcZtifzw})9`*|Vqj*|1WD5#aTD=m=L=emS`EAdy*?>y4 zHnVJn6+clTYbsQ#RODJ=VpEQFnOQZ5S_(t<=l2M93EI;_Dp=x2?g&;|vT!ll7Rb!L zE^&WYl3=-XI15+}VCFqrS$}>jkS#0HL%5<@A6?UbvIx&i_XQ|I985NIawVluJwg$d zBpF`CQfp?d1YFARFas_xIHKV@*Rvv)21N%v!s+x50l ze=-t!=9MJ-MjVHpGRX13Fd3{qMyB#zbT$17%#hEYYinHsqQ?Q0QGfT4lQQvnh$XHo z0W*^oRuv!5&U*tcCf&Bx)$MDoO#?I)oa{qZc&)3|{a@WL7k=mLZ)6EB`f zcpErGAS|+`)r>#sR1oGP=UZM3@v7te*O}XMg|Z2b#UW*Yj_lTx5(c9AYouF)KkaBD zU*TLu7HX5<@NKV>ZRjN+kiJyIW5PPQ3TcyI>oJ!G& z|9k%UY6fR|6MGdM?T(uOm?VI*3ex*M&YW!xMny;OlQ(>2mOrlg%M~1S?{u1zbE;pc zCsl)hcWbc|!xB1;Y=9+GR;j_&Hn&~I!L|N92XzIAKY_Dx1F*wGREO8 zD6i`pcM(?v8B!dkQ;ZIaL~0n>$HbdAcT~HqoGg5z);53eYxe6c{$7LXO&3LWfX8=0 z`ggqu9(LlNh(F3YI=-Mo1`8IH*%BDNA0kHBvDDIk#+$bwnxpy6@ch5+IjCPF+Ur1tTSSbf z+8MS5B_naS^&Mh%+HS@X6kE(B6#Z6Sp(z!P{^`47+NfJsiBnG)z}+eH%}B~BxAxci z2^9Rlk0BPq)%jzEWl0RNr?E#N*HWfrELy1w6a?OT?%7wo%6-$AzZTxO&!FulD$!lZ z99vL1J1-^H7i!igrS#iw&`)nmKe!WYS~A|7KHsh38g|z8wv~v2C4X+t{0~3h48t_-G|a(?wAkqlN9tFcO-k_ItUhPk|2-&Ox+7R}l_>L6GHWrTp-xZZ zR^(X0n>J%(KZ)rLgqPrPIY@H&8q)U5yE9@g$>1C5i2S+z2MQf$EsYyfafKPeyuT@T z;-<s+}i0>o6uMfoUDI_+Rqz@?hJLfX`3Qd6vH#y!F;L}8IC1qqT z@8~)hh`}6v8v_#)laP>ZO76=J&Roz?5*6x?&(Bv9Hm@dvF4;vh`(>Hthp=;CWc(3d zxEsvLQpdtMJ?OqE@nC7&O*-{2ZSxIudU%gW^q(oAij>h7e8#(hxw=nJE52K zE+7^hS269;AOsoB8G0mvhas}qKf!2+!^--vSqbWBL7Ydf;+Uuuf_A42axtB*F}%`; zr!ND5v7K+w4WgQfj&Pbbkp6UaZT4bdW8;gpgb$0=pn2hgs2V7UJ%4ZS`*SXpAXN&g zKw(LF`P1F;+}%ar*avu&a5`z`IzewJOFf8ix@^d9DOpwOn8MDe3u{>l7i;w7_7<62nquu3QA@`DsH$+cG2Q$&W3Gcz2TII~A}_`}XG1A(F!v)DRr7SAJ`tXIo?x2nrPkDomVd;mZ_a~Ff1 zTBw-6Hs|!Wamjrf+n}4(Y$4ov)bDw;ba}u7)X~>qw6W-8gF4x{)O?rfV|HB8syy;E zMfnrI`E<HuSW7k@^sW5~uC^S#-`s|O2HF8y*Gr@qGI5UG z5DSaxLce;Bs7h<2cQ(^W?S17|)u!wTd}@(a+x9m6rj5T7WNeMPr8J!PS=Ze~$^d6N zl$ef_rssIJ^{H*tpgWHMgHS8OFi;o{v11#Ggk$~eX6HvL7Nr0o8KLL#2&sE1*i>05 zU+?Y%q7ncBd?8pvo+(*(mr|=`=avgDFyP?WE*B#PA+rgY0)Hn;*mh=3LJ9aTFL{nG z_U%nx4c0Du z0oqa;l}vaVYdMf&JMMZm@2vu-iqdLql1&3p6FuAA9B1$WY{Uc1kU*Kw^~-~d>eS75 z^1exOt*$0?g95Jg;9i8<0~_0QPaE;N!hfUu>Yzx0lzY zz4wq6z^oay-{^Tw9o0P#R8&JHU|y)D-b#ddd9?g=8a^#P>yAqBu)7TsTo-}PESnj( z9yrJW%rEy8VBZ!hjQ)nCQ7ILDPG#?pq2swcW4JtA)F{`7n*(y%=&LtD^4XGOfu;$y zx~qLk7C1ou3y~lQxRV#5_nowpu{Y+ycRWsRAw3`zvW!0*(X}yfIo%qm(ij+w%MP8q zNhkFZ`Vql2%(J5%JFo;~U04j-wAHcH2P{{Izbc!YzuY7MH)@=6R7v5CJl^NIh0~!uO zib{hb_2wM;KS7cYhx4@p^z?_L{=`{f;S+uiPsy^t0!pb%Jkv^yv8#0rV8DO~uvl&| z4Fn5#9F}dMNac-Wf*ROY3wQN(D%@+nOQ_`0Z6Gs7J-Z+ve49j=6bGNCMq}6vLu~5X zBOxw6Q|^E*F$-9_oty7F9BQ5a_U#WxH*2Nvd$}*f>5O-5xUQSr^0k9r9g24O zC0D;)*bi-_52Z~~pB1!hX8wLwt<%~=O7%`@A@LIGn(P{LKf+QjoSgb2JI$C+?Z0Jy=_&Ytv~tk7f@y-lP>;FE;bx2gWL&u zx3Oe%XD`uvGwk4UTE|;WVE&RH> zP~1t;F`OL561NKW64Sltb&h=Re%lb#%kTWIOD$gA?O*+O9;?+Ssloktc9^X(0~EWr z@9-R+pdkc1$fW!8!XW8&*XrAnbPvMP^t3;(1wbYN6woVcdcD$kIDx~bon;v1ZN6rywU*PH z2Sm{Ns#oU;{mIdLL!%tMup5fxVFIBEeHXL6qx`6W8Ld-z|Wt>%GGm` zr$kLghgEm{PBU-h>(w%l#1K2Tt~=X2@VPAR%ASNholuQ+k=vztHeCU_mGp&Z@SB^B z^)PLs)4B0sl#Hihuqk#mX_A)rX5=@J{^p9tbcf)Lg28ZZCxEV30d?wu%kt4oIp1S6 zAZR16hrva$I6_cej}_~LJ7ThVb*rp(r49)Z_bJ=Qq8{x*?&U!R9}J_5F$`r%>n+L) z!I$R(uKiQRj_?}41pR_A`#i)%-mPOFB11^frN@B|4bdI-nQq|e$-7vgQmD*kI>QJt z@n>Q1n&Yh1D9}}ly>Frfu-UV?)PrXr>**G9F(_ZtlUSb3-2x{kufUk*yi{`2(ecql zl#@K3|G)@W?_jRFz+resX{)Gn%9Z1*JEBLmUMX*t{O(5%3p(r@&wDJdQEouSEJDcW zI(1O5`@@0oXdKVTOCE-@~=?U21LZ^*AVDiM3A*7 z@eq0S_rW6F<2#VleE<>VZ$+BTT+fV0HG(AJKM^rCPclO6arGt{qB1w1Q7%tx9~+W( zU#|Evb=I73&c#etLowR)dZ@<#AxoIF(<8V2{K*v8_p|fCk{9q?Fk4L&A)#b>?0&@3 z8;1BI3EAMCPenvVt`%q1#sO%Ed*N)e2kP@^Pq0SJ48o$76vGu-N^5>%hNwj&4$&wG za-*=zi$7 z)9HMsywe}`KjIAj*yq5A?}G3@moDDP8P8JmXByJ=D|IOsj41~hQR1m;Q>3K6soDQN zyY35B#svy3Fqzo+X(np3hu2{B)7BLe0?GQ(7qV&jQA|v!K-CWc_>FQoh@X2--~C|r zt}*O3>jG*b{aMKri(VTk&q8=Lva4QW<$zBES|^rj@=*SH+BBx+Re3eMN)?e%x*uB6 zf!_`wB~Ld&2ev+O_uvgesRUyMZ77$>q0f~SK+Xj9{#^BZN9#Yn%{;~~tN5J7U_HKmyrB!08%p zEscmtEzTL^C>7LW0ujWD1Wg%GnK4^2GdY>3gD4!3n+~ucG1O|2&jmjTKE?@eTdJ=? zX<+|BZ(v=q@Ar4rF{MIxia$v;f;0F-W@9Vv2AlJ1qBqzzI}@Tq(1&9S+nHW7+5&_* zaPz&yl#HR?R2*aIoy}j)%Gv;zRhoqKd2ZH-O)|5mCx4n5PD@^1-Hd95m%e`ZUv%Gv z0C$6Q4k)Vre-k4JNP?&X8!e+fUl75!F*Fzh(-1YLE@;8`%JuH1iWehKNJe22V008%J;fI5x`!0`b_S5f3& zTV6pWM@xr2nd!+l+QWd!v~6uK8-I@m;fonEO#WW&PsQe8OUVb3aA>*Uynn zsNF_3&d$!I2M0Vy9Lpo!lhRYrPY_>K=vVJ!FwoEhRqBy=vE0AGEmsK6zITSJkB$GI; zkeJNVozAt%KNko}E#B)}n=~q5UWz{VjXi5{7UQgANKRUZ^>mCZ_RgbCF%1gM|5sGV zJwgvk9e9W_L$t|-KYZ9dmm*e)YzK0f-)|lH5g0 zAnzp>Qt47QfQ{{78}E>`Msu+ zVc=1IeD^mQiY~v3j#a41HFlu_iwvKKrNEWrg)tXT`MQ#?3V5Zqa859M&^}ic$&8-&N~ej+TI#Sfj=itNcBB?`(xJm(%U} zX-whKvd>7Fw^kSed1~#^%zIeIUKROPp_*@em0$k@>rL*7*)4-ucrWheM7u$l;5GzAkNjN~pD!+YGYV7{1zz$o%*KydiP#mB#)O zK?P_9yfdXa$&92nDgeCNafw&ZI886xPT@%Cg;J5d@=1NYNbmx2vLRDe#o~09#?|Oy zyBy1jA>vmp)pD}syVi?rFI5%C4rYImv65;TE#_1@ZBfIW5ZtmxEDIw_MAg%Ccvbnh zZJhnYk!yuE-`gtI*d5QluqXt=x{=-e$)?h9>nx2+%USsx#%lVT!g~9b4Y}6Oe)r_> z0TC>fM@1$~skBMjhzoIJIF44WV%A|>rBt0l?k$H>0+mb22mdh5R`0Jx1qVDXx#Fq3 zav36*2txHDVBbY$8hAu`CI3$a1rb?6gxD8WJRx$uZ;O06D}C=~kYH6Nwk<@tZ}^ ztDo@~Q6=$Vt_3FFpT?3nz9&4rsgFT7YLr$~F7j7isHzt0YU1zxEXuA0pG5ng8cxL&e4U8ygR7sxFqH;rtuI>P4iN#P{c2BXW!G3dZVAkQ!C+SlW z!=dDQ+w?Ci#>0VYW?McNN(}-ImqlW3C2`ts<(j>=Wz?6-EwCkwa<}?0K9*)JET)r* zaDmKU_GF=spIC$-=>i>}RZb-Dk0L6D(9Lf0z8lp{bJn3!kiP0u++Q-OXoUcvB zhud2Jm>AC%-(k5nqudjp9VrUMf^T@pQTVE`1|I3R&EdBNqXnl33jUlrawe>f+Mjm9 z!ov51mlOG3^Cd^l(y6qw;7V_Oyu3ri9wg^hM&V?-U-Xop$Y0JAY6iglQngyric*Ld zCa%x>x6y^<{4R$}4kvGC6;*<;Y4%=!T%99Ko8@sS(S*6ZdbX6;pJ99BUCf^+RAzFn ztW(J>)ni2RX()^NlBrc>`B>d0A#}QFGsO^ZxB8<*#Xj!M3w3(q;m(4)z$xH9EiVzKZ=GKdMs8${1#@Jm^%(tKsYL16>lr3Wh5@;l`!@zJ;6ciG+MLgs62{$2lMj&l)m zBNKNH54RVME=Nq@TzG$ieF+Jm6NCtGMIh>)Wz0=9>l^DB2TQU~78C^NHEX7PWXyas zd~8yvq#!_Zsi>wbLo0=aPlT1=7Gy52XTZ@=a7Gc+s8V;cQxBjNtF>OXo^4XI{Q@f%N+^{apuPJIaN~cvwvj_F_vck|6ng>9naLpe z_`q-3yVc?<6+B#&!B8dIVT@ctf>GfPtg7Zyb#jIthLGRF`?-X-iFpOLJELT`^02Vi zOZ`Y~mBwdNtv~LsF}(D&{DZ7*XgruOT_^OwZc|%@4X8o7vF&REstc3T_oGO~P1;lI0pLjiy+|}k|#oFsz zJT6D+L!0jqpdjVYMh~%~az#^G?OhECMijcap?)zHo`XtE=j7B5NPi_$53uW(ZpM zui+Z$ez$|U3-CBL>5FDk>WfB#EFUu&R35wy(xE&ViW?azGwkK{(D;p({?2qdpC*4< z(nhfiJM`WODWy>0@z~2lFX1!>dpIqI(#d@5>@|NWEgfjxp7xT29E_#)!upSVq%)2f za94;n0eB(P8{BOc%3(EVykc|C`}XTygK!Qx{jw`y8TG%v9{*O}3K;Ead{ig|_i^;G zr_G5RIeSzRYqR)H+cM&x!Zlboxq`iJDf{Gj`R#$%ZnTb%CaUR5y<@>%qJH(l z6fmBC3rhtf?0nl{9of-Qt;0HWJWVoQaHO6XsA3t_te-n&&>(ZS>djYN7u_nYLj2<$ zY{G8u(24u2>6_n5elb@IJ`T4yKpXFbi2F-&L?m=rRC-(<{=Izk&lq354vZq?c0qkr@3FBX zH)lRKFbjSFCf$rrHw-~&fI(3c)|YL>SE*W!S3UlS*bEmg-hDX!{LMa3l@Gg<@Q-U> z`LbuaVESi$aKi+)KDgbGi7K$oM~h;yE2tuoZMhYTl+bFxHd?a{70r6Py(iW$IvH(g zzzMQM;lQ$+=AN+e6f?v@d1?FLeaB4y#l0_Kc!VqZHWCbdy@#Ljc1IXBAED87%F;Zc z>o5sISs<={q;dNjy-%mx4wdo3ogL!1EsGW3X~D1xRbnN=@qV|PSUy)07fpAFQylNB z<9bMf1ONSsk6NW6mKz0Vl{wreWXFvM8a!`;ELXeJK4LK_XAREV{JeDSuQLVzTV%lWoMDDFyp2(S#(`Hem2DSa8&U~_H z0lFR*xy=lLZoO&4ANtO?`f1zQ_MBPT+BMAQyJ=bOt~8VZnsXs-NZq`w`)={>;sA5U z#P;QS>ns4y3Y77*btoZzg+bC*!3z0AlL8GFl^_`6&G-N&r$o5sh4pq6&MB6CNQ&^p5`RKW~>lC z_RN>x{rO3wCa8V~pxV@Poj;v~DopoY3EW>FsOZop^N@YGbu{Su^sD35NBQAImRuRn z+zXA|;pFT{;vdMw_ls>@EIHwj7W)K|9k#*UWTwsGdY0V7#oDKlRWij~m*wFl^HIk^krA@sh>6CCjA=?KHpm zZnQ9Oq|c5HUlQK4X{OH4?li__%CXw7q75uCDvciC>L`%TrR}WsO+vslX~5HTpg)+X7UziEM+*FjeneS z{cUijUhG+4Ar)7eWu3np^;CAnn)Gqk?2qibxfWm8+Un7yOhl5 z@(30gk!z-P1=VpjJ~b1K8tXH(wvGIc`#LB{NFnTYGDC6N4IUgDLn&zH_xtq@75AIG z^+1xCbe2s}<#}18mddSE0Q>4oQ-iklm1y4?7|oT&(Ik<1uurEK6o@HL^&~dity9+f zRGTZ}^IBd9q<`I1DlVYb<{6*1nwj|JO&DfvSNeUWe!Nbonb-#}hS>05VqIaVPW$%Zb>3XacMpcUfuM2b2xmLSH%8E8L~%l zIzICmYU7aXd0wcj*1v_+jFPlSKTkJ&f%~<6X}B$6mj`d@c;a9yKoxMA&j> zsg>K9su@s!&2G0C*gD1i*H-X;^8}W0OBM$5o739B*G(0HK==5|hb~L;Z_=aMh-X_~ zMgSZ(;Db&fsahuZxLau~;Cih4*g;Xzn{{3O?uZ_gL=Z%{H^IFa72oNy4w5d1Hd)NN=C${K9&o+0=-9-$8bxhSXhf(`n`q?d!}|e2GvvMC$NDk zVFMll!Mh(SY){7Uc}tU~yC?$DDLvb^-<_yi?N(rBSy4XnZw31;2cf1Gp{Anq-|e$+ z$IY^q9k$TRN53NytqkXAT`{Q9k!(hG4~D9CZsM{m^DBSB{Y z0`GT0g3>Pjs@zWmA3`4Q1)I#VTq%tL!@!8v5Xu9q(&07*K{f16-nN15aAVZT^u~wR z!40naOy3E0noHO*4lpQ2N6RH=!HoSLkzH#(A zTpQi5-Rw7SK}GZpmO>`=zI6VMIQvslj%;bz>9$AAA2i2gswW3`#U5=#8%Hu$-e0K=v0!w^+T!5oWV*AHr1(=p1SLn!7_{|N*aK<(BL(H zI6t1`N<}W>4UH?i^l@42ZeCK0>U=dqO=UQRM>Z%9DF&>lV1aR5O~$RL5LNb~*vUY9VKgN_J0`SYAZ1slN^zqIA z_Fvs6;8yxQUJHPx2Ngvooc&W`rO7abbTs3qy$N8K%^k955|Ok$n<@sRmmI0MDeuHf z-xAj$ojUP@EXkNtaBFkeEcL~*1y8GalC2*{WP(v9s@M6BztC1wH;47Y{XHhl<%@sCJ?Vv7~gEyJLh6RHI9-l{OLPK5M6%cf@qfdi590@H*`zJM2d&39CmJ*3Q8IwXgqiSJ=j9Alp5jcK%i{1)XFf$ zYN_F;r^;QJx~{HZ>_CF5^cneJEVa<3E$NuRr3F&ycM|25bY6*WcTHuAl^H4dTp1%D zPPaLvz0K#rPH0MF4{VLju?VTff%#Ni&rQz+gR6b2SwIeY?_l?*_*x^hv{x(jr~AFJ zUN?l2XwklW7~*p`s!_KY+)eXVU@lg5EYm&oR!gtYWzOf?j@Y9*rf{mp^deLh9~TTD za=6d1@gfK@s6J(H8g$Dtrq%mA2~9s;s5TpGfw)Vf)omTGPuuSHxFX`)kw!F+@thPD zSh4gvOis50WZj`+&17?Mg!4mMjV8khvWQf&kKK@fLDyG)ADLy3HBWZonS{PbOCKYoKawwt)&Po)Ol=DVU`HPFadsa(Ek8+7b)>lr` z?oE=~h(f&S;EHzlI`>|RI~#%aDx0l@OKYH_f>5LX))u*?x%1W5kwFMVr+7;fbJLr) zF2*7tkvucaSqNsj;TlO~VwXU>6}gz$$*%I7WozbS{d~=FcOt#Nkx=B7*{M&>5v^R& zPTgpxNN^_KW3WyikSD;opb_o zU*xf^iK%HGt=agamV9f~=W63-GZtg3#Y+ylfkc+0uVLVo7yxm`4D}c%cI7ZeppoRAgS0W zu&Xd=*zGFw*p~Jx<(P^6bvv--IO&PMH_V`kJ^n%%Q_L>q-s z(3{CZ<@E1WX5$mZTES3gNfB;hu&Bg%gc5Zhp@y+{$Qs@MHw)mlxrxm2a@g#VnA2P2;y8+1jGf3T5easzzkTN`?hXF}4HGrfHS)2airD8J z7XL$598mE53I4R?Iscauw5~uEy_Jmn{}_AAxGJ|j`gZ{WQi2Fb2}ml9bO=aEBVE$n z(p@4ABHi8HozmUi-QCTd?0wGp-}~Zz?p<%UEY@B;&wS<_<2%MLT*P~H%)Q>Mi-%nD zaJ-2o@Yho3ZwlGlD~9F_spP}mN|WHYs(aghHdLtjELRFE`MXG)A1<@aXRK6lzOFBl9Z+4j@%d~KLdW-yU#{e?ggbi>T=a~@5nVKm(z zS?OOgU?%H5~#Jx6F3dC%}^HOkZ`UwXIEFBGF5UJ|8s?mH0 zjoof6JQ6yNjDiGo-X!tATY(UAnM`rVn_l@#8{ybbuz?8OC7Qm4L@GF{5+%&SYOh%_$?ib0xU0rClNJispXF+{5 zCvvPx6UbSrUNlwDlo7;Y&lHOc%5eh@*N@x&XYwlaHq*CaFZyhKvEG6QhGV zgSz4aC(Dk)BH^8Kr~SG;VWHO%)j%ldlLr@q=h8>&IA$Y(>h4*`^A8amus=;H23~wY zuu*eAKTd5_xwBUpdvGSa46~f_YJMl&h%5`ve~cDiFuHbZ>)z`0GOO87-+ml1Kv<;+*AGeF~OqSb??um{{7{D)wf@Dm)r*yexV zoz1+-v6U{bgBTW=EBiU{#xxLv_I@dM3{($EB-7G-=5oIL-YfSpoKzAxhNXUyKtPD2 zQB_b8EHVX^qas}4I^rxAd2;4wvyD&9MW8Hnn@4jQI6hVzy&`0Q!1^dx!aLv;43xTR z;3Zu4o+EyB#mf5a0W3#C4wrRs^{umO)Vn?8RO`SHRjn~VloEc2k&E;~92ipe^mo%= zE6}g>9g#qm1$pnA(4e58x~X1!@j|?&QAD^o=ze*7hekx!CU(%dR?Ln1fSgOC%Z9NM zve9x&iw8K8l^F1WigGMEwOi?8?xThWEVUnCbaGA*&gf7A2SeGd1RgUsi?%KZz?e+t zR9Ri%hNU0b?%|iJ?2hNgGWbb{d|M4j>AUNty1OI`~+WnGhk-Z`*}$3v0>X3(PXk^73;A2b5;N;w3*m0P4+RBLyt| zg&k+2qy|UrPH&{m8nBEBk9XXYel0I+$}|sA_f`v(rY*TXY|u3aA3W?B^wa-Ba$PKz z!iX}BuU;a)vmb1Lg=xAa2m6?JbIoI^3!Ja>{onru_DuxY_?9qTL1Ttsd8cm({*BrG zHK>J;qLJkY@N&5;WZl73uhc(SA^FN#VFs$tI#J?T7$K`zm#>wz*@Vh;OWXl;;_((k zZy~CKn*5lS#pW*UP8-*&YsK}qbH(IFDnq~H51DKrD4Ze*Q8EqvVyg zA)GW{d@GG~K`Ih<&$fPN`Q~xD+`gFl{`BmTC;0YaIFw}ly%-er+cZtE1pte*Q@P_9 zSZ8kcjt;g4H4K%SX~*gO7>DUf8&S7AgYh!>x6#|9E&d45=^*4jk=6!4{_ zwR?{rr`0+31w-2-YzRCkKWNc`foJ;(gMT>Q>J5Wm$gxKVM`75bE^8qgUIU(N$qX!1NjAE5G5A5|gPS#x;S6e`YZ*v!|(Eag_T* zyraW&#^o2j1rUZ>9!|fn_hEAhN{OY3&De9|N z`rv~=90;yIsD%c@wZ9uZTiyZ&FBnhx6$|dg3trF(r41sHm#O>qhx4h{ztcVdl~xDR z4z@P4-y0>pItP}31DHz9W&h~1wpqB#Vp?ZCWqzrY0Ln>#y`;3S`_L)DYO$al(g&{9 zJ!WI|X3H~@uOTy_eWBiLBJKjLN+8Ed_q;e9qG4difGIPb?#%~0Tb}rUXOlus6yM*y z0&W~Qz+`*($9`Jjg3*2}n>Ap#m~k>6D|--%1TvD6;p7vmH|2qEsVI@0&-Y!oeit*s z0>{yA_gtBph5mGE1nkVqqFwGD{(uB$Nf(*a;XIfM-hXAh2%wt;^d!;5e_h$F+#~en)@~XBj=HCYOQ$wWz1=}6YJ9*);5S*_RT;Q{ znWR=WU#e~66+augd>AC2> z*G%Uq_kq7JFz&HuTETiLg503cbXuI=#q6Fx5RAu)o!n3&c7-Y${{vas z<)G93FTc+i^gAC@-K9sE3tZ;nv6P-XW)s04Kuzut^);yHS_N}}YQ|`0-(YN!yv=Ya zo@|QI8b>PjJpqrl*93>`vD&;4Odm$HG3xIP72qj*_Le%JZZ?I`hXIZ7*bJ?t*IG-^ zHL${!bQqeB92`U>W9Z*MXeY8e52_OYz%06CxCH`f{w5zO`O!|EcX($5C{N%Nl=0>j zcqCgs+y?7({eDaRXGk3z7pn@?*tEhTb%zbzUBoh*p}Ssp-QP0OR5;$zNJ~N=fe{1Y zqfhK^;*p}5bZT|fs@Ugk!vI>Rj^SJb=YR~Fwwjj-pIkfJ zzM>kn0r_tziBm%<9fU|9PWO(u55`P7R&V|ycs<21u?M(1l7R)ptLetLO9SsK^1J)7 zoTaKG`qucKZSJnFkMtyB-G#t+8sP&n)XokDrBVv84vbdrEZ}>Ex(V>vU4-lHRi5Zg ze|^BYx=Z$bMHqX(bv?xWpnE;kJZu?o3WR`r4kuIK9Aot-J*r;~mU*{&BZgK~p>`0T zHl3aV+$(n~$TP-EvejhDV*f<_3LN<&q;Hd58Gf{74QSWe*8c{CrO{HZiJ{>K+xCAv zxSa)Xw|AXJ=1Ik}@XO6zUR`1pE~dJ@IrRmbO(I+zmisMm$AGMe(xEx<7@iG3iF!$} zx4pX#mohq1x9_@svG4$oT%eGjL>&r-VT4@T-t7ibkUB{nf^1z|{X+LJ)`ZVJ zoAzjg5i-|1IYN0;rd5_O%-gYMD74pMH8bUUD2Ry22neZxa`mYD7kpR{2(g#sTbt36 zEtf2+cyK9>?s!Xn0{@Ob8Q0IY!YP}7?zfuT%}rN@(P($;5gcl+e6@5s+(n*T(FY{2 zpFg+HqzF%cA$S=9XJrFBJb9*SU=E~-2gBH{K@U9klea_^wb3`|DS9ZGmtf4u!F;J} zU4_w-(Lw-&wP={x+@oW;V>wvVDvf7;vb^RQE8py=`kq6?Tz}N!ndXfIdUV7eUB1po zD~d*bcr;#A6sFN=aQL_-#txz$*y1_TByU`!Z2+R%!*{q-n(IhfZ;X7Gq-Q^S>J(h! z8iV2Sh(PXRHfo(l&~blo>kc6e%GF7eY94C3yY1Tu+yuXOEwH#5GdO|MU90D3Ey~_h zWPN(CSQPD({_}<_=#MiTo;-hZznHBb9KT@~ zU)+zA3vqBZmcc~C7J!az1X^e|VHG$)CfpxId_MGZ>-J)=F@=Q)KqV5R|Je7|($caK zWf-^6BudmBj)&7G;fHsh%K5U;^w6AwP= z%(sr?7OIc4LK>s+s08#+ue(GLWAE32{yugJwl3Yxm?j#I8$eep>h0ANjSQM@1*}~Y z;ir4AtGcV*8GgV|Q{4f>fD%P#1o3xzK7E(r*s8oj{Um=#y3!t%6tos&zbt)2MOteM+d*Pm84{sVD9X|RiN41|#ccA}o^hTs z%d^S*;--MV%u;@b2dOovnvZFWGb!d&;|H9U(U{tkT8b!iNL4(1^!1awS=0Ja>rqJG znu$bn#?YQ3|EmR5(WBa@Ha4TTiwb!Ijpkn=ae-mIK zN3=sM+W9;A7);_)%$JFc0>ULNVo$xBA;J!zLh3)$$xkp1<@fj{u7TYZ00OCvNcDqU z*-WYjL`chD{&__wQ=_>s%Y5Lii?CSZc;>FgnLb6GRb#o<$-`;4*kJ1nTG%DC&z?m! zWnwcCisE>jPrp1810v4b|Jd^Hn?#fU| zwb8nYPqF!#r?MP*iSHYH7}okLWZw1RA!J*cY_JKUroM0QdEz@t%Nf4wF!drU+& zTY77xfc;F5U2S71vDxF-38?W(0;xY?A3xjpA#2Sp_yntRB~K+aI@u_K!#IXUrPI3*|If|VTZb}afnU{< z)ms3l=ZLk4Adc)WNQp2wpeeD`vcs@Xs&JoI*EwJWRe>6LSi~10%MSh zG8|Sr#J)tf;>QZ;bHdu0!J<`{DNqXiK>VWm{v!Iio%VZ0hGA%H=)vJ?hnTicA$azs zsAWrQ8K1vP1q@+Kt z5qOMbSyBcdTOqc-td)wWlAnrN+DSZ$*o6wQvimBYb+a!LvI~**CO8vhu=*S%a$B#o z3Wgl=e)JLyP;3{3K|@WY<92=6A?3~f>9@1W-`3iFooS+RtCoHyk|mCV11U`gt5E`v z`+Ghu?K1h#h`^Nk{21bo2}UrO^%44a720=`9WR0-C!b5x$z5_PKn1%c2>o=for`vVBmIY z-etGs!dj!hAjI!>pR4VCn)bX{lXB&a7tCb{QlT2us~^N=C{ZdDPGsJ}OEb+i?$u9O z%;%_-n;$p@gC+56Vd;lds$d`vIbo?c{Gm3Ifz$pC_UDs@vSQo?iq$L#njB))hO@t% zr1B;UuI>oKPdECgCaa7l%B0Jq2*$XPwj&--d}e*h<8 zK3@|{T+0xx4m1uHiv<gX7~=sxeE2Y(Y!bb<2YD)wca_UHTiAbL$UIYYlw136j~3R216V zZy`jNd=yX}aE_lsXKK$EfSE+e4r4=*Vhqmz68ki(uRSOzJ~{Wo%n3%6@)Am`yhK3> z%&AH9K5t|7f@=nh&GeE`?|Z(kkYs?KnSeP-+(%Q{e`wyFzv1h01otlxyJ=z=+th_M zTBfMvRhcXl@*+M8YAGpQgGnmethXmkCgbG(^3D-u9^W}flcls<)N7rXrCWcEo1h6M zK1n7X&OMz0dM^Z*L2laXr=;3bHE=f2M+;Reb(dp#2}w~=L^Mi2xV*Y00E7+Q?v;IK z0Xv1{<5ac%5a1gLmTLU!b>=TaE(0Bp9v>jS{BBS?^ulT(*q2`Tq(>wbbh~P41{7|C z>tp95Q~q{rUhv>)h!HHd-b74Q%5!cwus7oL`P9$*Ufl4uM0_g2264ExEm6`<%Kb)y z(Hz&8QpQ!o`EAC!Uex?Vp(|o$o}&OM-mao_j4LCp_XP!dOWbs}!|#_KaF2gTEmyf} zt<`F+BHdtjx+2|(daxvS3po`wz%c529~-({4Tj#pqQS9DF{&Sw$ByvvOPW0u_XYKcb2eB+XQ>`l4x8>`g1THih9CZ$;Zhv)r=o~2C9H5$@TfFM(m5ZY3Q4?yL>m#=IeEcvr}{3A$kh|(q+~`V1U?> z)9aRg35j@2j9NNYhJu}<91}{@$r2Qp7Q!5)UAR&)h&w*k4M_MlOmWNuItB8`7ywst7l z!_I_bDby&%D1>Z2VaC5Vb?VD{$nU{6OfL~kuR?}AIUXb4r51SVw}nbsc; z5WnQ%nPplW`Kv=PEsr&V!Z&-2&j)$O%gYM~A_e$HP~b8IdAW@J%G0h#MXh>6%P#ku zY>n|Hz0p+FDcgWC0kl%-LAwNGFAAbB1DV^XGH7RpxP{ScwJM%9SpnY@G_Q{6!K+=3 z9)Jbi(-R7CQg29Kiee`KZTn9MO2!l7Cst@Q)*n_u$zRv(rPJ?9j(>zTp%&hk+E{h} zp~UR*C#q=z5(@8p3pxG+eSHR8C24N_Tk5hGg5xa#H%nxS=~fs6u=a*aX#OE&FXgeMoVi&OY8mXO%Dj9-Lm^gA}T@GLgtqzcvFaFjG^16?Ns8q zMeJdjhi$ddpZSQw+5+sq{XQ=yZKz(%B3f1*bSywA*v$BeJ9h{m#noD8v_LZmLrd<; z38`-bB6WE9D=Gn(%k?(6)nmfDWY}&%P|)w;1uQJ$&vhA1BA@H$3W^gbtM4#r-_4%d z*3X-*(A7~d^6P&UFU;8ra_>!IYcAkonZ~ub_y=GrzN}!b>2S#L-(*rmW_m&IpfXIx zu;9`UQ1pLZeuCF{U_gr8uuN9nU2|Q5RwrY96$Zm-(chMP&sah+=?o8rc@(W85AQ4e zK=;Y#Ik5k}9KM`X70@qjLRGmaSN~GFAKbj0ohUX#F8=JvR8CP}hOS#%RbFKke63do zwZIJ@Zp-$q$mzeo z=sKwW7xJ;RG-bb)$qjPjJ=D(kwL$!m{9`gfw&sXqC{MajvmP=W;be_l%`Um^9nc||cK4vvqMMXt<(%)OZ~ z{rLz~N?SsuyQ1GW0_qiXN#j*YK~n(8hJpV+h?*3_Xpu87g$ zf8V1^hdq1@{QEh4MH6C;)L>h|wRA6<5h?oNZ0+Hv$vk3(Nd_>BBS~i+G8pFCzxqHG#J~aB??0cxWBWwB8y|&b zPw91Te#FZhAyTF%i-qrza&NUGA3~MAST*M+Yhni|H^WRAtwaC4w0M9wXnnzfg3Ic@ zx6(hsUR{c(C|Vufd)v&_9as>u z`mb)JwPkr_dxKh)DU7hm4 z60x$ptdihwUrdX!aT4WOPpDWGGZUg_Rs2gh|NTnub$cst2I85LIIWF;{3xOR-lS~{ z0#{f=$!ZNs(#q5621mdsa51&p*sTQ48u)yi_6InO=FraM+jVXfr0d0I?o!v*VIlD# zkuailG}{=Bb-F&-Xt>z#QoN~F55|K3Gzd~qz!mjy}Q@81L6mtkHV-vN47eRj z(@}nuKMV%s%UEb2->4+m#1&5EI3~hLC=@DFA-ZR6F}m^9OR7=I6`k~Lm!|zu4+k)# ze5tX740W4OkW0`KQZe1zcj|y*Nm2AshYFGOh4_+iarLK%dO#?V4Nl^RhldXdU6jB< zsgf$%T)nzXX{7bD^OasHio85)p=zz(!>&aWZi#uD&}}!v@wmfJh6?o!b7U4wuLIEI zh?48PqZnSN2BuYCcl$U9=Vzen0}S^!bk{&eXYNUxT^_3`lrL$d3i^QP-( zZmytCK_JYbznEu?4;X{!8yW4OkA}tQw1T_Qc`*4ijVeo<$l#y%56Np?a=CIRV53sz z{e=gaMlG-XXDy>-GJCnjlP7)AB*0!zjz{=sOLYBL%eMyK5^MqmK_tsPU~IJvcqaRON~es;qH2~ zdSjwU-4jaUyfoB==4~*Tq}HH2)4jq!+c@jPJ+~o$CaV5AQTMLOPSgU~#Ti=MFgCO=ESK z<@%7Bs)Jl{Qr5T=R8g5DhrXXYa+*wd{g&>+DxdqcVp0wfNX^R)n^a0(i{@b;sNcF>Ds zH3|DDE9BH9f=*HObUqAavo~L=p04kDhx*6+JnO&uD|xLi%Wt0jIs24fX5AdF-|xQ@^4e?>SYkrsWB~j}$#za_7x+82wfHrt^_w z+gEEGMWFfiX@B}7YXVCfcE)Gz^_&VLSvoZ~`}u8Y4OCp0i&^H&ap72ESXWNT1g@Tz zvA;`3e;^Q|Dn3Q@PlK^;MBc-}N&|mWVe_zhEtQClz<+v0Z*y!f7ENAn6Vidy}C@2F|$3e@_ijj-@F`u?MezS;=@Dg@30z-?ms+!N@ z{wE&Euv_5F)Y;zI?T+6sYxKTXP2sy`e!n9I@AA~drs93h5{R%JiSvi^?HVO5tHz%{ zqmORg$5K2uHTx!+dhg#M;KVSS$eGO6yno0kbwF@6#fN%ToNuK&Bt*_mBUg0YNohXE zVy^Ubbimmu6;tDUq<-7tXB@ehL!^Fes$Bkk{p1*kq)~W9>L#Ybx6cX*aig-5()1(D zQ|Ea6A?7Y20;>I|V;etqsDiIDetm+F79@{zH<8Ivd1y6B!Fq&`>bu{wm)J*JI$vzK zTrwMKLU>In{-<5C%%qNzOC)hAPf$iqTFeWekMl#u{OPor^ffh8For@ypLNSWoYfXf zshv{E>?D0XqMvPdZOn^2rIK0t#xf+|;U_GErIEO5%~s=?)^~q&>0qT?clqf1K_roK zq7DU*#GK%y;Q<(r(Z%0md7L>IPw9)5E1k?RB1y1D`DYb7z=bc3UuyL;<;8j1UxGkz zUQXn3|Kj&Urr}Z^JWa{+Yt0YhE?WWM3Z7}tLZ0@=b}qw8o1aE@2e5+N ztppGrwvjj+fl&xhhzB8Wt6D2&{L?il?ZU>m&iq;nLl!X$Cvd|V0V*-+Tv8CkGj2p^5%w0M6;xJ#{6W(r&TH>eL2Jh z%K%{o)_5uRxurQhNR`K8^s`Di-fT@l>Ug?-qWtiQ0N-t+cLEH58Ql{)+5v&U@8x#y zTrV3G7VVb-P1a~URiH)abbw@QGLrCpokB0Q4w{ zxKF`VnS)&^L~9tcWrF3X&aN`x>4MN3O$+60=OGhdkHU>8SgbQ+Dcfce5EE$Ec=q-r(X+7Ko%bS08D*jjlBw zy7J(COSM7?W5{UY-aSb=-x~!XC*al%s8nTX1WW8Gz$&Y!!K-T&=?CN4ZqGMbU2vQW ztvfRpH$H;;Dv>=WCaaZIZ(Wh_vTKwF-jJJ%tsyvMd`35Dqj4}~;y3msziuRFmBn1n zjB2h-W{|kMP$xwr_wGpf71*#H@i=@tto-ybjH21`Eb8mK+h0$>X5)U~eolUBt*m5< zV%*s+M{7^m!!+-M<<8k|kMU1?( zf*Fj39M>t^B-*3?uDYAAZ|zr;r~RJFUzBL`K*o5a`z1_K_WB#-Y%7Zx7u3^$eAnt@ zs{&3qe)z9%#4qV^YNbY#MQ?AwS%uVN=j|bb9O#u1_yQeys8aR2I2N0^2O_;Rmq9666v&)e_6r zhCnr@0)8@;MNeuE1ol8Y_9nJ`>uWH!N`RCwtIX2R~Rj#C5cIr;Qh&T^1+81Yl>e^(d|k!`g>VdrOk^=7JmU! zBDhvGV^ruWsG#k>JrXySexXMXZQb)tZS8b8mpxI1ZAcwg|C&J3il;T_o3&4%GHtM^ z_?0YV&&t3mx=6O9fLKk#TUjc8o$f&=>d^LG=}d{K+7z`KJ`lL{f}cy&LrH!n6Ml9A z1yOIQRC3-i{KEDEzz#!S`P-JD4Lx89P;{FJZ@S|m~ml) z)Y>e+)|*!~Q$et({NTCqSl}m{v@|@C+K}M83Vd9wONDqlKtuvd$Ddd{4mY)OJf)ig z0R>deOP}@jIc{%C$AFyuCcE3mq~RD zZn(+Q>ZGcp3R9UXEKvI7F&ow-KHW^q-k-6mC-ZSn27@0829o9p-(Wa0+iVl8t@I`U z{eVa-kLXlf8~Y#WfVY5zsIH;E$g;VA^C50G+E)heR*~c(-+$SJ;ib~bVzJPyq9T4B zgExLG*{apZCZt}j=!Y8(7$fN(xJ^>Blycx+i|WHkQ;8DKBCUo;HwaIA8h#+u4yO`jjI_@_t|Rp4u19T8}D0 zx7@ON+F&6HQ_LREp&xACMEmbu7<|zh85XIZ-xlJ)QNxbK?ZdlQmEq+hF!5{Oc9*L# zs#G^)vaCRv@sJpj{~$$hV|ve6Cc#NMfLaGyQ+@(JZ_r+||1=B7H*M4>k0!nX=ZYFS z*S097wn{%vsmn}z%`*~9tUO=iy9+eLg|tjbkLT+X^o=$UPmTF?D%Iw8oXPyJ$f9l&;rDZd0@OXIk_{+Q zAx>u~}bso^P@Mc`R$P zQjoWAlwe1hNQ_bON5l#n=?!YO!{6@O)lb(b7WnC@RcCu(m47gb z!+&LpXk^`}@2_tEx*ty$*|7xNVtjRq=Ou!im?aE4wWzMfLK=xhI_R3u@9yyyE0JMp zo&WHf9d3FtE!X`3)-Ix3)Mm)8KQ^S_Kay#sxJJfYIc!P|VhxM@1PeifZOuk``4Sjg z0MiLjsl41jajQTX-T6pOU7x6*`Cl!df8~eY_hE2*gI7a~fLwmaM-Rbc!l55z&`<-g zS#c(@@_$tbXDZVUZ@MS z6*b!~1#}65mo|wTf~L1YX&E`jO0NQQKv4G=twNAU-3gP$^ox{v6<=5*(;Y*G$T*8> zRJWZ>OCZ$Eq$6HJYTO+R1-chAV8n4_Doh~0pJ_>5b6*<=;Qqzde12b#X}^%Ep8*3b zEM)obT~R#qjch&(|CFDg(d$dpY_olBFC<^&rG{WcO=hZF`l(Sz#e^#j&Q{GfFD~uG z7`HgD_IHaRZxEC|VjJuS2$6A6d0-wK92_^GPXVTurt|wIB+ICR-Dnwb!^DsO%pxF) zTFu8vi`mJqd0gLBWV9(XfARKIA75ON>2-stx+Q1|*`2ER8BEhZY*_wf!N#q2a{?JD zwxS~Rmyn`k1Y?g0zoe|EK1X9+#LE4wtUdc*u)%(31Pu=h3%afT3<@EjlO8L=gx``q zh{umr{ew+|ukU?U%=rQ3aNTG*UrcRo{wb%g)@fL`?eR3<*A&||2H)S1CKcNc#6l{T zf8`c~dY?oq3n&}vY9mj&8r~=cLgk?oVc~Fzr>8iDI+?o@S3c6=b&GYeVoswB{0%86~%1Hlt`HzDEekGf1J!&>g>jP zMaVu|vJ+n%S$w~@Z8@-HY`xZrLP=^n1=xuHSHU+I(sJ(2xXd$zUnZRZl4 z;nQ5E{X%Ut$GZWWDcB=L;(qY>_p6N@DD{)c{t^z3c@9raudM)|E%7NptXaL(>EISX z(_u8IH$=v=srj{;lU8~-mvl|f`@XkBfpvqij9Xa)^l6ye6mD&C8#Cqgb#57W`^7ZW zb3t)k=7d}o*LHHxVYJ#n>IgX(Uhf{hd}@o z-Jf$O3fimtIA*}*a{h;f8Z=|>*PdA{F;pA^*|_=swqEM)T&2{(?MLe}54pr|&?*zl zeG>vy@IN1A$vNcedOjS2q;(1mC%8v*DT}{1#(mW3{IcZ6tUv$4$=II%Fo0pGk$Wea zp3ULpen!J>@hRV;xfvXjFdxfxC`4?GUG{vKPce_{=x$>js&(pV_7n#aI8LTd-mH<| z<8(A_!zzQlIp_$wyw<25YQi>@l9aCb_Sfvm^a?v$w&q(ZKv5d6+hUTD&Y>S4yV|Sg z>@tdcf&c!A-!I8*p@@XWWjz0=0XERk(+MYsg3sLQWR)!MIf1)QImW-+k#cA;8^A&C zbakLnYFrrF{QFU7G$QT-SDK<@(e*BNzFq7%l2%JoTh0!VVcPU`Ip;t5=lyRuikvln~lJC6}b-86fD`Cr*Vr;_X8PlVfFBO?G5Z}Ke8r3?P%*@9} zOUY4?p$5A%ZjvE2Vmc<}dQ!_b)?rrdpD@GtvJhLZy|0iUeq#S0lrLzy-Qwqf8Wjn} zf`(E1&EVhj{}7@D$ZYb&pCR9$M9RL@!yt%fcR0Ye?Jz&~rvwy}?K;@_|ynyHmuYGGw{nbml(_EHq%lOxb*DbvR#Z zl_ehg^OaXYGawEBcu9+jjRzw3RLe3jR!!fTNd_El@Yro7LZ`TpXpgolbA}==X(XsubRaXNiY<1MPZm zvR0>2XyEpeu*HK@H!plPfk}(n_5<>*7PjpM8#4{*I&I7!W^ zjW#Rumg!$zKN}uTm`n-N52=;>0J;Iv*5J!pyyv96bNu+)Ke~3S^FrM{)2Sk5s-&G$ zde;Ms)#@)x!mAy{X7huK{Ah#zKU$#LVHKNJy{^G@Wj@*G;kZL_>Dv?njFgb>r4w&O zQ++>Vp#4R|B&oops9^u2iMTrH<=Q4zdOm0AngccnRezsOSt33V7XUVMCtU$871T7xE=6l{QGL%o4(jue-f`6h*7LSpD?Y zM1h*zp9-h&%mwR(Z@xUKLr8%!#nCs&a`wIx>OvgGNN zyw&?L%DPE6D!KVbs7)5KZ_&3V{2hbPl)h-!8!@6DWfDsXB8^umt|L&xt&6Vk0^T4Z zB^mMIHH}=sH#gv0qQo`Cg0rpU!5oX(Jbf99$m`0ZaT-ALfGnCVFqW5JAP~<*&wDEo zIf~cigOz%9f-u^(kO}5>&bbimZBJH`&$G_m z@IyMvKsfpE;mMXn^#W8^GV?bl$|7NfP$T(!&-N}Cy|qM{nd@i_%*0E z)V;}f)L{QtWABN3<8369{pywEF(|ovJ$F(^7v{G$lw5o*1Y2gT=9`x{yY?ziJe=GX z*k)aJM=u{pQq``2ln_S+=g32b^8|k zi1x%mgO4w0U28=0f#_R$f;PEro}AHGRwnGHaDe^_E`a-M;CrnxigHbxPe=l%bzY-Y z^dVt}Wa4cw;)d0RG241qvWt-hT=nQr#`%zscwd;d&5`oh+gbDCk>Y>o9U>&gGQ=rX znjeP0t5wsWM6`BZ&r1vFHl|{XGTSb(W$Z$kvl7j(u>$k^Adq9cPx?oo$y+9d$GvHx z{_4|{NW=#%5Q-p|NW5JB>!obH*>?(1G4o@Fu~JQVlY z9^?Z-inH`>Qv$^h=#ik+sBOKcaIPxInBMAFMZ?u!kK?d^Y%uhFwezfF=Bv}Gk{gAQ zo~|;kHW6?!Q5>vIlk@P9piLB|#Zh67kK6iL=6EuI`ULAmcgL0R1ouk~Ki?aaqJDs^IcSNlI zAz-wj!YdKsYv_p7l}KMx)K!N1{*FR53s!n8H9r($BnVB|JQ)ti$)~zhGK~Sd9`Fj` z2L%A#4nf~yuEBr}0&jr26EK>$f{arWD9((w=M{Scu&F7b1Z92n6-AX%3|?xa4XS26lZz2=eE0cEs4N zzSr9|if#nxI(*`Je>0NHKW9H{?}l-^UGJ$8MkppxX>5XTZD_bXXU53^jSqs^+qSA@ ziast+16|4I@f@!Dd2%0*78|eTa$bYX;N0m{RkF}y9GRH@DS>LG=@+ulgM8(1p`(j! z`Ev7gP$w!63M^Oq_Mja5CX6StX+4S(IcE)Yn!voC_Ah;amJkOZci>WmW3k|g{C~B8 zda?^Pxni}YnD}q<&5x_)AHvcY7Q;__vXd`%E?zg9B%j~rfC{iI$@@fZUu`q6CX>>(X1{#*Uv7i6Q4ek2ENjEYBI+qy1$RE{EpPAn1 zG`*G8%q3*pd(k8=9e2Fg7Tm)B2<};+sQ~zTh%=*Kc;4G2bV((Uk~L@!B0Hz1eJEDU zt1?`qHgfnsdb|J4_1sn~NF2Aewr&glVF+3uQ*X5U49HXIu3|)xkBkF-Fos=Ias>(| z<9=G81JjO8ZZ`e~J9oFu{Tpc#AFPdZp*8mN*neFs zU25_a)~UriG@5lD;98tgwOvQd0*Z29`nF60y_2+cLluX4gGyebO!N2dH?N!Zz<$DQ z>>PU$-CNzN6*cbPX953#eRa)48_auT8=^|OeUltNs}z4FDE*ehUqpD*xM?#Q{EYja z(+k$v(~UR^mjxwV%zKl7DzIOEo7}@Y44%y7Qc?TEsU#A3tklX4 z*n!a%W@mQ5L*+tjd4^d8&-EWf80o#Kb!1TcgtZDA@HWlP{)Ye#f5EEsP<` zuVC7AOhiT0HhdA2mCe;xoYP+}XP=!e3;6FRfoPmiAVmF17Wb1EyG69w98c)~WZ}J& zA9}kHI+XYRr^p3mUgs~I{jwjnvao@Z1)PWQ_1Y;YM^oedotLyL|H`c3LkR@SrEnu? z#%3y4|4a%N)(aNbSZfZC+F9JG2<~UDpnDj5r#r`i%&Mr{vC3US+X)|!J0({iA8>JF&;k`S%e1eX^U~ODBH6b~ z`L*I+Bh!p(DBlz_jYRFdxBhHqtYuAILLbO{c!LWab7)B7ZTZDaME5`6JA??D;LUzn z4s6NcNq5}Q)F+RgJ2I4gWY+WIEN@O`*?KD&C3zd)HvHG7eu@*rEuGz(CMp^x*bI!Y zCFU;;SD@+(w2}(qzVc9U70ps=FK)j3-(Q88jQ6TPaKkg<#;?c7G1jc5NVcYWW$*`j zOWj8gOqp5^#X6{0f|j`Ho+9XbhiUSx6UAdMQzmIxV^#n9yfG#9W)au0hS6aw9Ui*> zCZJBs*7D!aY$}k8o{r*K)u*FY-LELYI-oLu1wCMJ48o$K#RoyUQ|#8F^}|U<0}}%N^On5p4ku3Xn=Uz)|# z)Ln03h?Ewz563a{hZXhBxTv`^Inq&bdS{yE8e5w(u=)wYX2rjuJE2yNyCFpX=Z#t{ z{{Myiv5$j4s271G6r1}BGz~tdTZ4M~CNL{`tj=Q^TjMRN3suS&S2O!?ASbvTL$^&x zns2CX$|8|xao?Ew2*gj8VAQb8^v9TBk=2qus`O5>6o)Yti;@!9K)+8o4u>q;hqx3Pq`k*DjvjWyR&~pFU}kx>NXvW;f)f&qe*)!nRL>0|HBjcLj&keUNA}zmtthI|QC~HX z;Od*@@kIOncj7H6DPa$!Zq`i~6WSGC%!i^U2Lyrwo0(vIP~k-1^Z&a>6&;|GbuN0E zK9Wsb74iZC+?Q717~#Bi^P;=#Rb;h1NE%s(=71{4pCD|b`u@OYGIU$Kv^WTCCa(*5 z_mV4Z6~+S4Z);UfrvW!PJYx!!)-=rQHK?r3=cr`vgAYk8fgQ_TTnUupmc3FVz1Xo* zu6escXA|>57F1W$DSl=|x%NOYIJ$HkqSL>fc%SsOAFvk6CLTHPx3;wj5+NgfX?4JV zA`J+Q0pZDjcf>|EEVb4(l!-C6U^GTRaEbQn8lr0kks*GiQ|;J$IR=QmMnF(#+Nop& z^pGRpdT4pS!l-|cR`2&&j(FErGnC5wGSNm_OPymw%p}_b4@L8;l)G?ri1pN#SrsQw zdzY@H|Lo7u^F4?he&)LC-U4^A>z{6H3<{}P^-6r1FX|1`v`6ToXvmIt1}X;E_o~I8 zGL!-ihC-5Hq0+(UV@svpxPFIdU^H=iA9L(BR|64mD^QS#-~05BzX4v%`~Rv`4MEy!2Bfwt?%hCOD-si-z-Tgi;3GM-TY^QM zuUS}THv;YU3G4mD@t!_~*Gc2$%fUQzR87`I!#p!&J^E_K zZTIId2KSDgUWxu_!WyHqlMD%zU{V%o@1EJr(c2oyUY%xEDT`svpulY$B8`gW{ ziZLGn@3%{j+A_yI@|bLHt6@J6CdnHUSNQ_%x<)WfRBrQZk9{NecFtYhP;MHt9?Syz z!Lo1bfHbWRqF6eG9t&@U#pO$6b(Np-Z1Y~3F0VqvlO)D#R)JiH6nKkB>QvdgfpQc; z)KP(`d~E{SKTwy2f{J<|Y(zndJf~5tEwyI(Bjsx$B08CDtZwy=v?1U2$Mm+$l?zaP z!z}B#=C!r4BnGf;s`8=mpAj3~^67_Lc>MDy9WGMaR2nekZht=fu>8^=Ev{8P$RN3m z$+?2k0X@Ay2MKGBz1n5c)oR?Np6IV~z*qSHBb+FMPA8ubS=53}hnDUCr?S0eLyH|B;!5@=q*Ks-vMeIqf4b01N z9JyPR_=y~P?jp+ca67y}%$Cm7R*6dAxI`OkW2xET&_5G}J0c)==mYfX@3UJfZf-Sv z*PSc-h56@z$*B~7+Q^NKiTSc#3k#nX#6D8_?L_%{^ z`+6i!A-OubtKd)QaN(E414P7-o(jyzm=wGb1ych;d*xmod4M$O1#B)rDVqf9-hy@E zmQHQRSWbk|cf}Fkujr6*i1^uDTvJul;++W3R1kqY-2%jnI^0vt?HehcdhXBu5)1G- zH27uk+_Otmr3sJAt12s~ z`_@k^Y9HDy&!uJB=$7JG28mdimlami{2`qPfKCK(bhn_+-skqmtF7DnSb*uW&u!}E zuR-H&ZtLlqB>)4q-UC>8;5{mW3S=!=#ls(jpA>4E>D-OaK@`JHchRPNKo63+l$36Oz$6x)Cgda;7nAM* zdRkB;d*@-J-9d~5VwS7F!;@gJtl!dP@r3v-4IzZ z1-!a$Co0RmS6z4gAHa6^UWDSS3X7K0g_}Lil`Z=&fPJ$*Ot;~mo&PUFIe5} z^e8-n+8LJtT_V!qO`&rxTjON_?kTWmuvFs8oHa-F_BhSr;}8_obN)`M4Vdo33vGyDj6L_|Q3w97Xz$ONtqqpYsByOL<#lJ>bQd(=vdZoW{A zU{XDo;SAQ>0iJ7daO>~zbgq60&*X}t3Hb+oVpTU;+vcfYk$HyI%98$zR=r1!?DSyv zj$iO*_GObFT{v-pLHTFBSpnW$LqPX{7OUnm8^8P|_LflX;!bsrnAC$J0u~*25RB{^rG3Y8oG4+d zw;wMEOu4#~Xi*C@+(4YP{3MF*V(5#(9C<)EeI%RV_Gsrb6%hL@JIqnsOB%Col8K=^ zx*P>oZ|BW{?58oesoQ5aQa}(MCiiNmTq>e$*6pFz5*OBMmI9{%4O~dyCiW`F4-*5! zs72YRr|JF8<`R6W2HMtX5+=<0%5O5Eot6aa`$Y+B(tLT5OLBP5iNbQ9IGi|*|8roB zfbSOMB&Not6MB19BW3)%s_=VQGW%v=`YYa7<7;ymQ9S%t8_K;J1yJuD-mPk2J%-1s zWpwCf_zP4L6$@R{DI`kHL^ojJn&qJWoce!6KRK}KbOGYDO)6r{ZuA6TEQ<8&fW$|F zQ*a6gN4q?=u7Ny-ou8ha-neybPz5}8o-3dK1t7R@MhM50 zKLz4>)U#K}MNh_$|@|Yy!qNWoA5{nhK9e>WxR@098iy zm7M=XZ`4zzw8M}3(5A8M=MAk}!$fU!Bj4nte%{@T@+(mwf*CK+Kd5~D`{-^vIG^@b zl3af~!oAp&KzXvp__~K@JM3od$uG$r8DEP^;Bg+$P?q~M6fu)a zAAJvpo4R1b=N58h=H_PB=4?_Q1mch;Lhkce!8e^Zyeb&v&);5&1Kk>MDY>&7^0(dk zl7=bp1Ne(YV;itr1~fz#VQ*#l4ApDrs$}B=s?QPun?BWb48JlJzLUG%8PKoO*sc~K zm~BV<^?>RVC*hkr5Vcdl^M-FCuQu&I>nbX-_@hjs~Rzqu)55XD-(Z zz}}|L%{zWp6#o$gsoX0CmE^@I{_=(-p2*tEci~Ui<58R_Ms?Y(*`ZLdo7$}?xugA$ zByyR`nuX7#DcK!oHpq`GJ1PTE(z~kdUfBt$~pVOScN#WDVO4!pO49mks zkTD74s9seZ|G!`e*#)CK>5RblA}e`>Q1Lb_nM)7#5zsv)McMQZMuAGU^)dq&RrpYI zN<#(x-d!#t4#+fBY0o*NV>Xo9WlLKQ2Et3)FRwbK(v~f-KJ$7Yq0(ydJ~I8e0med4 zSg-EwbJfnUvtC)Ke3s}>JRCatuFna8d;pC2A_Pl8BBudR>4DtL)C>A0Jh>Vq&L9AP z08WM^zh_ zVGfj;7nvQI2pMEHy*xk1y>rK@^T5r^i~iA*|KKyn>d8JZ#W+iS22c)=0XMvvcQCHV zMq*ozdpl@c2o5!>pMEp>CZfqz?aAdCSzMp;i%+}@fw6FUTpVUtg}xelIVA?8n_qaX zbL*0CFye6^UZpES0k}yn7`RDI*gmnKYZ<=|Jm!VaR)T^G4XC=koF8p${zh(%;#go|f_x5(h2a}l>fo8NsJ&PW$E!yQ2_8%@^C77V7sTG}scsG@;U-1jJ-M^bg*ha_6e`JMTv}Qp zb6SOgSw>d;##MHuBC=?lhA7f-ha!M!R}r7r#BL7qL!=-RHN3hDT!?i{EJ&(hbv?-V zTQ_X-(4hFW3=AY`eMMXr# ziT|_=^OjolO9ykZGbsrs=HeZKp5pDIvq0`fuh1!L3Dy?saua;thT5>pF`Ltz9>h)# zYCpe9PE-2l!|p1(nKy9o@GknEhl}aY37LTq4$OnszaMRez9PSUM7WDSVC{C@9lvOC zp~I3KGUr|G=U2QOm4EydmMXtmJgjcVlNu6(Rt^q}E z2_7D^`n7<}_52fbc|+G?4E9eBxaZ8idpKt(4EZwz?no7dA2%RH#L$VN4|@s{dQ}n{ zx{Vy=wwB2Ve>eb`#fJ&7T5*6sy6K_i?mpSzspH___vH%(06v6%2nY}YRfz)i($~WT zhIxiT2INOCU_50Nb_$qok|LZVB8io48d1l?S^~)BWwm};b+y6rhTz2`*b?-eVcN8- z+@&Tc3`~jPZJEItBQKALhzMkI!HOx`(dG;Du=61tW`6ps>Q2fbVlqWHMI8(jluxB( z=YCS}>pq7Yz<8=Q&F)vm?K)1PzgAf{NtnlNESUU|Pn8@&pQB&I27HX}ev`#kdpJ4t zzH1T48x@v=81de>`--)Bi|3zBXx?d}0o@lP)QeqVnAX zD73rV?(H!~GWtCOZF)Z) zo92nUL>r&;xv)Ic8rfs0m$Hql^SgIsOYnf___2&dyY5Kth>$9IrnxhmI) z4YtA5^NX{XKd(0|KBWwp8?XwQc!VCD9Cfdh(i; zajq8+*DR4G5EOz}2%#E*ousN&MU6-TWy9qtu0G{exx}KM>?y+0iWSwpo82KW)j7hJ^neJl&=1x^Ke-Cwk+TMrV+QFN#{_$<%JKw{}(W3PX1%$f9 zmT|+z5N5;A15=Srd7mowtuNDU z(-a=2L>+Z7uJJ=qh;oljhrtVuY7V6W=tO{Ra-z(I%Mv!zxam+MhiCM1W4JZr&M?1* zUc}#Du40iBG$t*EC_Yb^!>$R(#Py^+^0Nh%ccamqyz^Xk%$fj%N}wYp`bP?V!i|md zx1ZL2iR)_W9S)(1a>{jtKi0*LR||W@{w`+VVT+GAdT5g{tvZHN1e{bGX0z4tV)xak zn}INdqrbSz$Q0G7-G@U%)C+w4&O9m`NQm_ ze-r)Gq_>*S?t++anys@}Eume5P^XVKBLg2ra6SCWU|Xef@GJq`v{avW_{z?+eX-?X*RiHjV}r5`Np(N?Veai zU+G-*_?U@!WrTslB3DNflZ#xaK*u~O7OmIyTmWZjX#D;cgYjL#j`yJP8=83I+wV2V zOFqf^zLpk}-RT%pnQfp^&?Bg$!zWRJI`=iAFp~#JoXDc%IaFzrvpHz~?T-zQ6kwP* zRkXtpss@B>k5<#XGl2vSe4V5vPo5yxyQ=e6@pRF#UhQCe6RBmFeKc`n!xneW(aldeI2AEy^=Tct5j%Y+O%5{{;V|+x&3}M)> zq_*F0WGvgBe^J5ppFi?_(rWo|QiS8cy^2X`M&pRiyrGkUDZ*~P_4H4r9Qd=tgmYS7 zmQ@%7&z_AOd7WRcTM`=ogVO>Bg^kh&C2p|!?z-vjQyPJ>f7U$kp+)VL_b}t$V4=+a zCLT8^TRq41OMi2Ie0yKl808;}Ng3w_FQQ;D@eZAbOEDgm;h$ao-JJ=-Gs|3Rfg*)V z=!2pnPtqxf94-s5GS$$GBr59@ZA;cV&5881`$s(w%&21_j<~{a&OVIwDL|m~rB0rn z=(=LIVT6W-xQ-0=J#U>d$l>~JP(b?P@%@WDq(MpFOoMTaX~@RL^NZ_MPDbvVGmscn z>zf^bQkj+5D`c9D@##kHO;uFnx_^bipVZ4m*@5t*zgsT5E*x zJh`zTyZh9O$&^d%h%toH>#K#U0^IHrr7pUTZ@HaUDXYF^FUsFl?(X}= zoo5=7K<1DD_;5Yy7FFHZmtwF zzK02NMjdKks#ayW?*lN+QeI^6yF}5wyW5&0DWY(|_~NO@g!yfrz_lqO&Q_Yb(1|9- z;n>$ErUxb2$$Lp3*(dr$Rhh;kjTy?K;O}3#udq0q@0yyFhLA;(cI~B0^yL`$@ej<_ zq_47gu_tU7jTou9qIm++7?1!W*GdSoVRrCYN)n+Oi)ngd;xoAkTO3x7_(y$rum8_Q zKIjdmkfWM7WJr#hg2<2J!4}08#T<)Sn{VRxur}6HRM`88q&d7xJtJN8Iss8eQ7KDXyV!k|>({%~ABPV8biR1% zlt&QrIu&;;t{!)^3YO#Ypv>`9=*vNi;Ox3Hx-t>sj48DD_)7zwf}POT`#Xe@QR5tH zdgDuIT7|1cR(o> zRNKr}sms7}^s+wO<&@TrmDa+^ zIIv7rOD%VTn7@}-M-xXg^bswYJF)L>lCn%SgX0$F4m9jFey`{WXGc#eZ3ox=(1h_4 z3vWar?_H){oZ-N zJQ$Me$?`5AyA#Pk-DR_n!GccZ4Ts^^99~-H5A1wScpVPT{TESsd%4gILgdVg5@Q3I z@-v$sq$DmXko%i$ekO!u)PcXr>}_Bne+toR5XjZ*Pba>`_1D(^60VLhw&Z|BXgks( z+LJVL$taLUo}h$j+n16>)bXgB93DiO6ym+6?xw(@Z%nX#mDM`me|$W zudx=d52~#@9IC@6MP2vw!P(^)s5AvUaLo~A}OB@sk@Jk$A)Kwom3wt(( z+0lxKuRwTKOh-o4$>!!vDv4s%7*K86my-n%#EPRTGfWIFO|WM)yPun2&Vz1Eeeu%G zc-jiro>sJr7<@Ytsjet9NovmWLx-O<4$@P$3C&rj>e7_DAY4UNhO;gGuyarEcT@7dFVu+1Lw z1l;Jn_wA@Lru3D-<*D}Ina5?&#%{o9;~(cSJufaLXML~#dcaGY>xtyBdgq?WebW>I z9}Z8uVdJI>+o_L~cutBrEHqak>#ql(5cuIHD&@G=Il~Uwb6bmMPH)rHlTu2n@ORC6 zb3{~{L1qdS*$`;L!%3|wiwC^JiX)OjeM=Ai(=Y{V#_v*%NvN#I31UgTDKxQFcH?Ip zOUqh;!&jaaJ09X&Z*T~nX_K23S8TCcOeW*=a)@V@cxfS=s-6>FVt7uyc6cyH zGe-O-9&%bonF(EsNyh!!1G9+wR{nifsJ1(v6QbctI)g(kr@9{3F=IAD-)e$s$B8#y z?z`IYu|3X&XMF+3fv!(K-tr;`Etl78`_6;@gE$1C% zX1Q?vTg`o?9k@)cF`J{16g5rqpL~=$Yk;j@YQJyA*X={}nst5#K{LC~{T;Q_*}iw) zGmjvJ`$wFtHxl1kyc4zly@FJOrb1@PiE{4eMwp<7=gTmjG|3@R94#EN%_#7n?pqTN zrX?Y#exP#{nMx%W2fXB~)yv~P1+fS|75E=8<{LCvTL%3(4bj_^Ai}DeVRfrms%>4_ z$s~cv^2Yr}Ben_ZH}~5l3bK_tfl+cMq$F8o5JAzV`bZ9kwwL=%? z62F!o>b-TPxM_6%8#VL6T@gm@FUgi2OC8JNrZwuzEL2)5o*#R&dY3%po=~&bq1Yw#%YdQ z>(w)vboO;o8u(L{d-uIY!yc#Xir#q5XU0t`xi*MJ=5r+Jklo(%Z_$hD>aotn_E%U^ z>Ec#nVU`w2_DDKyGI1R$Fv*@(`G)!v#vtzU_d3K&dr-n{c8atezhlt2=xh`B2GV6H zq0TL#9zHveC%UbvYKCFOL|XCOPY20p-nY{kQd6u6YpdBhc;{O5=gJV}5dBoEP8GZ@0$|BS&Rv(*!U7n?NbZmN|fMJSYp=MmL_u%eGAz%R2IFQcnn*>DMy#wI)U9R z7#=spj+5U(J`pChUPhPk)ffOd{$7Wvn_^#FetF7W|D;h%^7Z<19}7s5yB26m6?_y& zCupipd7$aSk9Gku_sbs3vqLMBJN~(7X(Jiqp_OCWA&X>p(YU@V<@oqNa7xg!SKGiF zUzc?h-SIwttMs>RJm20LYR;;+)%nF!&e znTsQs=DpN3CBprmc|aLvhm))PsdGH?NZf1Ugo@&9LX47ZZMex6f|P&*rjBBEM?44- zZMsV$guax{R-cP74&(@~O*(J2yUYIboG>uthc94eEJVsAVS7uPvr!A4`TJFp7=2z( z9DpkzvKddGSuNIeWXgT}29dd(!-r0x4ZxLpZNLGH{n%E2~ zv;8WoNv&gg=HWAyRjPdm<+FIG{VTa|p0&bSWcRt+71ZLgE8!Ks`1f-=NCn&alfzBM zK4iLtVx3w3xXNdDLNt2)3W0y7qeMHIl;S_7!=E{&v*-fK8hesVFeeHD>nAW?dt`6J0pjex9-s`DWe zis+a7K`-8p8*0DQ4SORa-8;gwuRlD2B<;#i;&n!y`Bdy6uhVozag9@pN4zxG&p%}x zZ;mrW<{2buD5=vuRHM~$pPy;H#xT>4?6`msMLWmhzw3E`k4%a9Rr#Tfy3kZ{Hb-Ea zmiaj}BqMnYjc3Op@U&hncdo0jEY#5DmP@k=h{|2IO{PES+E30TM3p> zVM68o%K`mS4n}vqz`;FHQb&DlSd*4z$5~c*oM;MiTg(YLFOkoanAy(b$d9s+S1RCl ze`>A!*?C(2sNneAN`LKZl0_Jj1{#CB97ZF{eUpo*AX4sxNR!%qqH4Cl8T*NTj#wsD z!o#w>3{qbVst2-~IW;F^WSkHRwoNRksY!Hb+^Xo1d2ek5(gI~HO~@q?nW=9QZH;MK z?n7mZOgH`_$(c@g7V;WqXv^y1MrH~@>h?37b4jQP+8hJpxKd%-IB>ddNnJVS3VZoM zdlw~!7CqL~O%)a&B)C)&0mr9Zc*o+5`Tiy;JiIWQ;CbDHuOOuF2GKcjj2W+-9{l5uXf1%GNw8*<^qEuH>6t$3s`)a6X~yddax>*K$v z576!rnPKL8Z_H+o4{9i0B?5{0>fFgm7Zan54f zNyA!eBNVSo?M8?DhyHtN`i>iH;6(R34Iyc&-iQt^wKlYuDn~gUYTnmqRc!pGw@NsV zXI6`(BAfVVytDBp|HM z+iA^+JOnIF1;oX#Gi{_f7?kan$hHvP|gv@B4)BzJ(twF?`ANc!>nkt(=bZ(ueb3< zVIfIB3XO0&2I~WmG{T;VN$gk{o1rI%Ok1_{XqQgat5cDOz#H9v*7wypwXfZ_rdvAU zJli$LgA(EpbafQN4m;+OurKR&HiDmYKt{@(OZ6bcBl~h~?a$VY0;3sz!@(RL#(iAe z{$;1O7k=~nXfdTn_^lwE&WN5Q3;8d(ym(V)4s~-*E`0TssG&Fj5X!Cf!�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 Yco8uZ5-Mox|5L>9k6GbY1iCMpC2$VSMA8h} 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