import { type $Typed, ChatBskyActorDefs, ChatBskyConvoDefs, moderateProfile, type ModerationOpts, } from '@atproto/api' import {EMOJI_REACTION_LIMIT} from '#/lib/constants' import {logger} from '#/logger' import {type Shadow} from '#/state/cache/profile-shadow' import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types' import * as bsky from '#/types/bsky' export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 export const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 export function canBeMessaged(profile: bsky.profile.AnyProfileView) { switch (profile.associated?.chat?.allowIncoming) { case 'none': return false case 'all': return true // if unset, treat as following case 'following': case undefined: return Boolean(profile.viewer?.followedBy) // any other values are invalid according to the lexicon, so // let's treat as false to be safe default: return false } } export function canBeAddedToGroup(profile: bsky.profile.AnyProfileView) { switch (profile.associated?.chat?.allowGroupInvites) { case 'none': return false case 'all': return true case 'following': return Boolean(profile.viewer?.followedBy) case undefined: return canBeMessaged(profile) default: return false } } /** * Resolves the effective `allowGroupInvites` value for a chat declaration. * When unset, group invites follow the general DM preference * (`allowIncoming`), which itself defaults to `following`. This mirrors the * `undefined` fallthrough in canBeAddedToGroup, and is the single source of * truth for both displaying and persisting the setting. */ export function resolveAllowGroupInvites( chat: {allowIncoming?: string; allowGroupInvites?: string} | undefined, ): 'all' | 'none' | 'following' { return (chat?.allowGroupInvites ?? chat?.allowIncoming ?? 'following') as | 'all' | 'none' | 'following' } export function localDateString(date: Date) { // can't use toISOString because it should be in local time const mm = date.getMonth() const dd = date.getDate() const yyyy = date.getFullYear() // not padding with 0s because it's not necessary, it's just used for comparison return `${yyyy}-${mm}-${dd}` } export function hasAlreadyReacted( message: ChatBskyConvoDefs.MessageView, myDid: string | undefined, emoji: string, ): boolean { if (!message.reactions) { return false } return !!message.reactions.find( reaction => reaction.value === emoji && reaction.sender.did === myDid, ) } export function hasReachedReactionLimit( message: ChatBskyConvoDefs.MessageView, myDid: string | undefined, ): boolean { if (!message.reactions) { return false } const myReactions = message.reactions.filter( reaction => reaction.sender.did === myDid, ) return myReactions.length >= EMOJI_REACTION_LIMIT } /** * Whether the active conversation accepts emoji reactions. Reactions are * unavailable when: * - the convo is in the disabled state * - a group convo is locked or permanently locked * - 1-1: the other user is blocked or is blocking us * - group: we are blocking the primary member (the owner) */ export function canReact({ convoState, primaryMember, moderationOpts, }: { convoState: ConvoState primaryMember: Shadow | undefined moderationOpts: ModerationOpts | undefined }): boolean { if (convoState.status === ConvoStatus.Disabled) { return false } if (!convoState.convo) { return true } if (convoState.convo.kind === 'group') { const {lockStatus} = convoState.convo.details if (lockStatus === 'locked' || lockStatus === 'locked-permanently') { return false } } if (primaryMember && moderationOpts) { const moderation = moderateProfile(primaryMember, moderationOpts) if (convoState.convo.kind === 'direct') { // Either direction (blocking or blocked-by) hides reactions in 1-1s if (moderation.blocked) return false } else { // In groups, only "we are blocking" the owner hides reactions const isBlockingPrimary = moderation .ui('profileView') .alerts.some(alert => alert.type === 'blocking') if (isBlockingPrimary) return false } } return true } export type GroupConvoMember = ChatBskyActorDefs.ProfileViewBasic & { // can be missing if account deleted kind?: $Typed } export type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & { kind: $Typed } export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & ( | { kind: 'group' details: $Typed primaryMember?: GroupConvoMember // the owner - may have left, thus optional members: Array } | { kind: 'direct' details: $Typed primaryMember: DirectConvoMember // the other user members: Array } ) /** * Converts a raw convoView into something easier to use (i.e. extracts chat owner) * and enforces the correct type for convo members. */ export function parseConvoView( convoView: ChatBskyConvoDefs.ConvoView, ownDid: string | undefined, ): ConvoWithDetails | null { if ( bsky.dangerousIsType( convoView.kind, ChatBskyConvoDefs.isGroupConvo, ) ) { let owner: GroupConvoMember | undefined = undefined for (const member of convoView.members) { if ( bsky.dangerousIsType( member.kind, ChatBskyActorDefs.isGroupConvoMember, ) ) { if (member.kind.role === 'owner') { // have to do a type assertion here // this works: {...member, kind: member.kind} // however that's creating a new object for no good reason owner = member as GroupConvoMember } } else { logger.warn( 'Expected a GroupConvoMember, got an unknown kind of member', ) return null } } return { view: convoView, kind: 'group', details: convoView.kind, primaryMember: owner, members: convoView.members as Array, } } else if ( bsky.dangerousIsType( convoView.kind, ChatBskyConvoDefs.isDirectConvo, ) ) { const otherUser = convoView.members.find(m => m.did !== ownDid) if (!otherUser) { logger.warn('No other user found in direct convo') return null } return { view: convoView, kind: 'direct', details: convoView.kind, primaryMember: otherUser as DirectConvoMember, members: convoView.members as Array, } } else { logger.warn('Unknown convo kind: ' + JSON.stringify(convoView.kind)) return null } }