import type { AnyProfileView, ChatBskyActorDefs, ChatBskyConvoDefs } from '@atcute/bluesky';
import {
DisplayContext,
getDisplayRestrictions,
moderateProfile,
ModerationCauseType,
type ModerationOptions,
} from '@atcute/bluesky-moderation';
import type { $type, Did } from '@atcute/lexicons';
import { isTid, type Tid } from '@atcute/lexicons/syntax';
import { EMOJI_REACTION_LIMIT } from '#/lib/constants/messages';
import { isBlockedOrBlocking } from '#/lib/moderation/blocked-and-muted';
import type { Shadow } from '#/state/cache/profile-shadow';
import { type ConvoState, ConvoStatus } from '#/state/messages/convo/types';
export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000;
export const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000;
export function canBeMessaged(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 !!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: AnyProfileView) {
switch (profile.associated?.chat?.allowGroupInvites) {
case 'none': {
return false;
}
case 'all': {
return true;
}
case 'following': {
return !!profile.viewer?.followedBy;
}
case undefined: {
return canBeMessaged(profile);
}
default: {
return false;
}
}
}
/**
* resolves the effective `allowGroupInvites` value for a chat declaration.
*
* @param chat the chat declaration to check.
* @returns the resolved group invite preference.
*/
export function resolveAllowGroupInvites(
chat: { allowIncoming?: string; allowGroupInvites?: string } | undefined,
): 'all' | 'following' | 'none' {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the lexicon leaves `allow*` open; we only read and write these three
return (chat?.allowGroupInvites ?? chat?.allowIncoming ?? 'following') as 'all' | 'following' | 'none';
}
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);
}
/**
* filters out reactions from blocked or blocking accounts to prevent their identities from appearing in the
* UI. keeps reactions from unknown senders, which are rendered anonymously.
*
* @param reactions list of reactions to filter
* @param relatedProfiles profile data used to check block status
* @returns filtered list of reactions
*/
export function filterBlockedReactions(
reactions: ChatBskyConvoDefs.ReactionView[] | undefined,
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
): ChatBskyConvoDefs.ReactionView[] {
if (!reactions) {
return [];
}
return reactions.filter((reaction) => {
const profile = relatedProfiles.get(reaction.sender.did);
return !profile || !isBlockedOrBlocking(profile);
});
}
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 conversation is
* disabled, locked, or when there is a block relationship with the recipient or group owner.
*/
export function canReact({
convoState,
primaryMember,
moderationOpts,
}: {
convoState: ConvoState;
primaryMember: Shadow<AnyProfileView> | undefined;
moderationOpts: ModerationOptions | 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
const isBlocked = moderation.causes.some(
(cause) =>
cause.type === ModerationCauseType.Blocking || cause.type === ModerationCauseType.BlockedBy,
);
if (isBlocked) {
return false;
}
} else {
// in groups, only "we are blocking" the owner hides reactions
const isBlockingPrimary = getDisplayRestrictions(moderation, DisplayContext.ProfileView).alerts.some(
(cause) => cause.type === ModerationCauseType.Blocking,
);
if (isBlockingPrimary) {
return false;
}
}
}
return true;
}
export type GroupConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
// can be missing if account deleted
kind?: $type.enforce<ChatBskyActorDefs.GroupConvoMember>;
};
export type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
kind: $type.enforce<ChatBskyActorDefs.DirectConvoMember>;
};
// the conversation routes key on a `Tid`, which is what a convo id is; the lexicon types it as a plain string.
type IdentifiedConvoView = ChatBskyConvoDefs.ConvoView & { id: Tid };
export type ConvoWithDetails = { view: IdentifiedConvoView } & (
| {
kind: 'group';
details: $type.enforce<ChatBskyConvoDefs.GroupConvo>;
primaryMember?: GroupConvoMember; // the owner - may have left, thus optional
members: Array<GroupConvoMember>;
}
| {
kind: 'direct';
details: $type.enforce<ChatBskyConvoDefs.DirectConvo>;
primaryMember: DirectConvoMember; // the other user
members: Array<DirectConvoMember>;
}
);
/**
* 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(
rawConvoView: ChatBskyConvoDefs.ConvoView,
ownDid: string | undefined,
): ConvoWithDetails | null {
// `Tid` is a plain string alias, so this only pays off at runtime: it applies the same validation the
// conversation routes' `tid` codec does, so a convo that can't be linked to never reaches the UI.
if (!isTid(rawConvoView.id)) {
return null;
}
// narrowing `rawConvoView.id` doesn't re-type the view around it, and respreading it would allocate for nothing
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the guard above pins `id`
const convoView = rawConvoView as IdentifiedConvoView;
if (convoView.kind?.$type === 'chat.bsky.convo.defs#groupConvo') {
let owner: GroupConvoMember | undefined = undefined;
for (const member of convoView.members) {
if (member.kind?.$type === 'chat.bsky.actor.defs#groupConvoMember') {
if (member.kind.role === 'owner') {
// narrowing `member.kind` doesn't re-type `member`, and respreading it would allocate for nothing
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the check above pins `member.kind`
owner = member as GroupConvoMember;
}
} else {
return null;
}
}
return {
kind: 'group',
view: convoView,
details: convoView.kind,
primaryMember: owner,
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the loop above bails unless every member is a `groupConvoMember`
members: convoView.members as Array<GroupConvoMember>,
};
} else if (convoView.kind?.$type === 'chat.bsky.convo.defs#directConvo') {
const otherUser = convoView.members.find((m) => m.did !== ownDid);
if (!otherUser) {
return null;
}
return {
kind: 'direct',
view: convoView,
details: convoView.kind,
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- a `directConvo` only holds `directConvoMember` entries
primaryMember: otherUser as DirectConvoMember,
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- a `directConvo` only holds `directConvoMember` entries
members: convoView.members as Array<DirectConvoMember>,
};
} else {
return null;
}
}
export type ReportSubject =
| { convoId: string; did: Did }
| { convoId: string; message: ChatBskyConvoDefs.MessageView; view: 'convo' };
/**
* derives the moderation report subject for a conversation. groups report the group owner, while direct chats
* report the other user's most recent message (or the user itself if no message is reportable).
*
* @param convo parsed conversation
* @param ownDid the viewer's DID, used to skip the viewer's own last message
* @returns the report subject, or null if none can be derived
*/
export function getConvoReportSubject(
convo: ConvoWithDetails,
ownDid: Did | undefined,
): ReportSubject | null {
if (convo.kind === 'group') {
if (!convo.primaryMember) {
return null;
}
return { convoId: convo.view.id, did: convo.primaryMember.did };
}
const lastMessage = convo.view.lastMessage;
const reportableMessage =
lastMessage?.$type === 'chat.bsky.convo.defs#messageView' && lastMessage.sender?.did !== ownDid
? lastMessage
: null;
if (reportableMessage) {
return { convoId: convo.view.id, message: reportableMessage, view: 'convo' };
}
return { convoId: convo.view.id, did: convo.primaryMember.did };
}