diff options
Diffstat (limited to 'src/state')
26 files changed, 2012 insertions, 568 deletions
diff --git a/src/state/messages/__tests__/convo.test.ts b/src/state/messages/__tests__/convo.test.ts new file mode 100644 index 000000000..44fe16fef --- /dev/null +++ b/src/state/messages/__tests__/convo.test.ts @@ -0,0 +1,65 @@ +import {describe, it} from '@jest/globals' + +describe(`#/state/messages/convo`, () => { + describe(`init`, () => { + it.todo(`fails if sender and recipients aren't found`) + it.todo(`cannot re-initialize from a non-unintialized state`) + it.todo(`can re-initialize from a failed state`) + }) + + describe(`resume`, () => { + it.todo(`restores previous state if resume fails`) + }) + + describe(`suspend`, () => { + it.todo(`cannot be interacted with when suspended`) + it.todo(`polling is stopped when suspended`) + }) + + describe(`read states`, () => { + it.todo(`should mark messages as read as they come in`) + }) + + describe(`history fetching`, () => { + it.todo(`fetches initial chat history`) + it.todo(`fetches additional chat history`) + it.todo(`handles history fetch failure`) + it.todo(`does not insert deleted messages`) + }) + + describe(`sending messages`, () => { + it.todo(`optimistically adds sending messages`) + it.todo(`sends messages in order`) + it.todo(`failed message send fails all sending messages`) + it.todo(`can retry all failed messages via retry ConvoItem`) + it.todo( + `successfully sent messages are re-ordered, if needed, by events received from server`, + ) + }) + + describe(`deleting messages`, () => { + it.todo(`messages are optimistically deleted from the chat`) + it.todo(`messages are confirmed deleted via events from the server`) + }) + + describe(`log handling`, () => { + it.todo(`updates rev to latest message received`) + it.todo(`only handles log events for this convoId`) + it.todo(`does not insert deleted messages`) + }) + + describe(`item ordering`, () => { + it.todo(`pending items are first, and in order`) + it.todo(`new message items are next, and in order`) + it.todo(`past message items are next, and in order`) + }) + + describe(`inactivity`, () => { + it.todo( + `below a certain threshold of inactivity, restore entirely from log`, + ) + it.todo( + `above a certain threshold of inactivity, rehydrate entirely fresh state`, + ) + }) +}) diff --git a/src/state/messages/convo.ts b/src/state/messages/convo.ts new file mode 100644 index 000000000..81ab94f43 --- /dev/null +++ b/src/state/messages/convo.ts @@ -0,0 +1,900 @@ +import {AppBskyActorDefs} from '@atproto/api' +import { + BskyAgent, + ChatBskyConvoDefs, + ChatBskyConvoSendMessage, +} from '@atproto-labs/api' +import {nanoid} from 'nanoid/non-secure' + +import {logger} from '#/logger' +import {isNative} from '#/platform/detection' + +export type ConvoParams = { + convoId: string + agent: BskyAgent + __tempFromUserDid: string +} + +export enum ConvoStatus { + Uninitialized = 'uninitialized', + Initializing = 'initializing', + Resuming = 'resuming', + Ready = 'ready', + Error = 'error', + Backgrounded = 'backgrounded', + Suspended = 'suspended', +} + +export enum ConvoItemError { + HistoryFailed = 'historyFailed', + ResumeFailed = 'resumeFailed', + PollFailed = 'pollFailed', +} + +export enum ConvoError { + InitFailed = 'initFailed', +} + +export type ConvoItem = + | { + type: 'message' | 'pending-message' + key: string + message: ChatBskyConvoDefs.MessageView + nextMessage: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null + } + | { + type: 'deleted-message' + key: string + message: ChatBskyConvoDefs.DeletedMessageView + nextMessage: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null + } + | { + type: 'pending-retry' + key: string + retry: () => void + } + | { + type: 'error-recoverable' + key: string + code: ConvoItemError + retry: () => void + } + +export type ConvoState = + | { + status: ConvoStatus.Uninitialized + items: [] + convo: undefined + error: undefined + sender: undefined + recipients: undefined + isFetchingHistory: false + deleteMessage: undefined + sendMessage: undefined + fetchMessageHistory: undefined + } + | { + status: ConvoStatus.Initializing + items: [] + convo: undefined + error: undefined + sender: undefined + recipients: undefined + isFetchingHistory: boolean + deleteMessage: undefined + sendMessage: undefined + fetchMessageHistory: undefined + } + | { + status: ConvoStatus.Ready + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] + isFetchingHistory: boolean + deleteMessage: (messageId: string) => Promise<void> + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => void + fetchMessageHistory: () => void + } + | { + status: ConvoStatus.Suspended + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] + isFetchingHistory: boolean + deleteMessage: (messageId: string) => Promise<void> + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => Promise<void> + fetchMessageHistory: () => Promise<void> + } + | { + status: ConvoStatus.Backgrounded + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] + isFetchingHistory: boolean + deleteMessage: (messageId: string) => Promise<void> + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => Promise<void> + fetchMessageHistory: () => Promise<void> + } + | { + status: ConvoStatus.Resuming + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] + isFetchingHistory: boolean + deleteMessage: (messageId: string) => Promise<void> + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => Promise<void> + fetchMessageHistory: () => Promise<void> + } + | { + status: ConvoStatus.Error + items: [] + convo: undefined + error: any + sender: undefined + recipients: undefined + isFetchingHistory: false + deleteMessage: undefined + sendMessage: undefined + fetchMessageHistory: undefined + } + +const ACTIVE_POLL_INTERVAL = 2e3 +const BACKGROUND_POLL_INTERVAL = 10e3 + +export function isConvoItemMessage( + item: ConvoItem, +): item is ConvoItem & {type: 'message'} { + if (!item) return false + return ( + item.type === 'message' || + item.type === 'deleted-message' || + item.type === 'pending-message' + ) +} + +export class Convo { + private agent: BskyAgent + private __tempFromUserDid: string + + private pollInterval = ACTIVE_POLL_INTERVAL + private status: ConvoStatus = ConvoStatus.Uninitialized + private error: + | { + code: ConvoError + exception?: Error + retry: () => void + } + | undefined + private historyCursor: string | undefined | null = undefined + private isFetchingHistory = false + private eventsCursor: string | undefined = undefined + private pollingFailure = false + + private pastMessages: Map< + string, + ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView + > = new Map() + private newMessages: Map< + string, + ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView + > = new Map() + private pendingMessages: Map< + string, + {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']} + > = new Map() + private deletedMessages: Set<string> = new Set() + private footerItems: Map<string, ConvoItem> = new Map() + private headerItems: Map<string, ConvoItem> = new Map() + + private pendingEventIngestion: Promise<void> | undefined + private isProcessingPendingMessages = false + + convoId: string + convo: ChatBskyConvoDefs.ConvoView | undefined + sender: AppBskyActorDefs.ProfileViewBasic | undefined + recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined = undefined + snapshot: ConvoState | undefined + + constructor(params: ConvoParams) { + this.convoId = params.convoId + this.agent = params.agent + this.__tempFromUserDid = params.__tempFromUserDid + + this.subscribe = this.subscribe.bind(this) + this.getSnapshot = this.getSnapshot.bind(this) + this.sendMessage = this.sendMessage.bind(this) + this.deleteMessage = this.deleteMessage.bind(this) + this.fetchMessageHistory = this.fetchMessageHistory.bind(this) + } + + private commit() { + this.snapshot = undefined + this.subscribers.forEach(subscriber => subscriber()) + } + + private subscribers: (() => void)[] = [] + + subscribe(subscriber: () => void) { + if (this.subscribers.length === 0) this.init() + + this.subscribers.push(subscriber) + + return () => { + this.subscribers = this.subscribers.filter(s => s !== subscriber) + if (this.subscribers.length === 0) this.suspend() + } + } + + getSnapshot(): ConvoState { + if (!this.snapshot) this.snapshot = this.generateSnapshot() + // logger.debug('Convo: snapshotted', {}, logger.DebugContext.convo) + return this.snapshot + } + + private generateSnapshot(): ConvoState { + switch (this.status) { + case ConvoStatus.Initializing: { + return { + status: ConvoStatus.Initializing, + items: [], + convo: undefined, + error: undefined, + sender: undefined, + recipients: undefined, + isFetchingHistory: this.isFetchingHistory, + deleteMessage: undefined, + sendMessage: undefined, + fetchMessageHistory: undefined, + } + } + case ConvoStatus.Suspended: + case ConvoStatus.Backgrounded: + case ConvoStatus.Resuming: + case ConvoStatus.Ready: { + return { + status: this.status, + items: this.getItems(), + convo: this.convo!, + error: undefined, + sender: this.sender!, + recipients: this.recipients!, + isFetchingHistory: this.isFetchingHistory, + deleteMessage: this.deleteMessage, + sendMessage: this.sendMessage, + fetchMessageHistory: this.fetchMessageHistory, + } + } + case ConvoStatus.Error: { + return { + status: ConvoStatus.Error, + items: [], + convo: undefined, + error: this.error, + sender: undefined, + recipients: undefined, + isFetchingHistory: false, + deleteMessage: undefined, + sendMessage: undefined, + fetchMessageHistory: undefined, + } + } + default: { + return { + status: ConvoStatus.Uninitialized, + items: [], + convo: undefined, + error: undefined, + sender: undefined, + recipients: undefined, + isFetchingHistory: false, + deleteMessage: undefined, + sendMessage: undefined, + fetchMessageHistory: undefined, + } + } + } + } + + async init() { + logger.debug('Convo: init', {}, logger.DebugContext.convo) + + if ( + this.status === ConvoStatus.Uninitialized || + this.status === ConvoStatus.Error + ) { + try { + this.status = ConvoStatus.Initializing + this.commit() + + await this.refreshConvo() + this.status = ConvoStatus.Ready + this.commit() + + await this.fetchMessageHistory() + + this.pollEvents() + } catch (e: any) { + logger.error('Convo: failed to init') + this.error = { + exception: e, + code: ConvoError.InitFailed, + retry: () => { + this.error = undefined + this.init() + }, + } + this.status = ConvoStatus.Error + this.commit() + } + } else { + logger.warn(`Convo: cannot init from ${this.status}`) + } + } + + async resume() { + logger.debug('Convo: resume', {}, logger.DebugContext.convo) + + if ( + this.status === ConvoStatus.Suspended || + this.status === ConvoStatus.Backgrounded + ) { + const fromStatus = this.status + + try { + this.status = ConvoStatus.Resuming + this.commit() + + await this.refreshConvo() + this.status = ConvoStatus.Ready + this.commit() + + // throw new Error('UNCOMMENT TO TEST RESUME FAILURE') + + this.pollInterval = ACTIVE_POLL_INTERVAL + this.pollEvents() + } catch (e) { + logger.error('Convo: failed to resume') + + this.footerItems.set(ConvoItemError.ResumeFailed, { + type: 'error-recoverable', + key: ConvoItemError.ResumeFailed, + code: ConvoItemError.ResumeFailed, + retry: () => { + this.footerItems.delete(ConvoItemError.ResumeFailed) + this.resume() + }, + }) + + this.status = fromStatus + this.commit() + } + } else { + logger.warn(`Convo: cannot resume from ${this.status}`) + } + } + + async background() { + logger.debug('Convo: backgrounded', {}, logger.DebugContext.convo) + this.status = ConvoStatus.Backgrounded + this.pollInterval = BACKGROUND_POLL_INTERVAL + this.commit() + } + + async suspend() { + logger.debug('Convo: suspended', {}, logger.DebugContext.convo) + this.status = ConvoStatus.Suspended + this.commit() + } + + async refreshConvo() { + const response = await this.agent.api.chat.bsky.convo.getConvo( + { + convoId: this.convoId, + }, + { + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + this.convo = response.data.convo + this.sender = this.convo.members.find(m => m.did === this.__tempFromUserDid) + this.recipients = this.convo.members.filter( + m => m.did !== this.__tempFromUserDid, + ) + + /* + * Prevent invalid states + */ + if (!this.sender) { + throw new Error('Convo: could not find sender in convo') + } + if (!this.recipients) { + throw new Error('Convo: could not find recipients in convo') + } + } + + async fetchMessageHistory() { + logger.debug('Convo: fetch message history', {}, logger.DebugContext.convo) + + /* + * If historyCursor is null, we've fetched all history. + */ + if (this.historyCursor === null) return + + /* + * Don't fetch again if a fetch is already in progress + */ + if (this.isFetchingHistory) return + + /* + * If we've rendered a retry state for history fetching, exit. Upon retry, + * this will be removed and we'll try again. + */ + if (this.headerItems.has(ConvoItemError.HistoryFailed)) return + + try { + this.isFetchingHistory = true + this.commit() + + /* + * Delay if paginating while scrolled to prevent momentum scrolling from + * jerking the list around, plus makes it feel a little more human. + */ + if (this.pastMessages.size > 0) { + await new Promise(y => setTimeout(y, 500)) + // throw new Error('UNCOMMENT TO TEST RETRY') + } + + const response = await this.agent.api.chat.bsky.convo.getMessages( + { + cursor: this.historyCursor, + convoId: this.convoId, + limit: isNative ? 25 : 50, + }, + { + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const {cursor, messages} = response.data + + this.historyCursor = cursor ?? null + + for (const message of messages) { + if ( + ChatBskyConvoDefs.isMessageView(message) || + ChatBskyConvoDefs.isDeletedMessageView(message) + ) { + this.pastMessages.set(message.id, message) + + // set to latest rev + if ( + message.rev > (this.eventsCursor = this.eventsCursor || message.rev) + ) { + this.eventsCursor = message.rev + } + } + } + } catch (e: any) { + logger.error('Convo: failed to fetch message history') + + this.headerItems.set(ConvoItemError.HistoryFailed, { + type: 'error-recoverable', + key: ConvoItemError.HistoryFailed, + code: ConvoItemError.HistoryFailed, + retry: () => { + this.headerItems.delete(ConvoItemError.HistoryFailed) + this.fetchMessageHistory() + }, + }) + } finally { + this.isFetchingHistory = false + this.commit() + } + } + + private async pollEvents() { + if ( + this.status === ConvoStatus.Ready || + this.status === ConvoStatus.Backgrounded + ) { + if (this.pendingEventIngestion) return + + /* + * Represents a failed state, which is retryable. + */ + if (this.pollingFailure) return + + setTimeout(async () => { + this.pendingEventIngestion = this.ingestLatestEvents() + await this.pendingEventIngestion + this.pendingEventIngestion = undefined + this.pollEvents() + }, this.pollInterval) + } + } + + async ingestLatestEvents() { + try { + // throw new Error('UNCOMMENT TO TEST POLL FAILURE') + const response = await this.agent.api.chat.bsky.convo.getLog( + { + cursor: this.eventsCursor, + }, + { + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const {logs} = response.data + + let needsCommit = false + + for (const log of logs) { + /* + * If there's a rev, we should handle it. If there's not a rev, we don't + * know what it is. + */ + if (typeof log.rev === 'string') { + /* + * We only care about new events + */ + if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) { + /* + * Update rev regardless of if it's a log type we care about or not + */ + this.eventsCursor = log.rev + + /* + * This is VERY important. We don't want to insert any messages from + * your other chats. + */ + if (log.convoId !== this.convoId) continue + + if ( + ChatBskyConvoDefs.isLogCreateMessage(log) && + ChatBskyConvoDefs.isMessageView(log.message) + ) { + if (this.newMessages.has(log.message.id)) { + // Trust the log as the source of truth on ordering + this.newMessages.delete(log.message.id) + } + this.newMessages.set(log.message.id, log.message) + needsCommit = true + } else if ( + ChatBskyConvoDefs.isLogDeleteMessage(log) && + ChatBskyConvoDefs.isDeletedMessageView(log.message) + ) { + /* + * Update if we have this in state. If we don't, don't worry about it. + */ + if (this.pastMessages.has(log.message.id)) { + /* + * For now, we remove deleted messages from the thread, if we receive one. + * + * To support them, it'd look something like this: + * this.pastMessages.set(log.message.id, log.message) + */ + this.pastMessages.delete(log.message.id) + this.newMessages.delete(log.message.id) + this.deletedMessages.delete(log.message.id) + needsCommit = true + } + } + } + } + } + + if (needsCommit) { + this.commit() + } + } catch (e: any) { + logger.error('Convo: failed to poll events') + this.pollingFailure = true + this.footerItems.set(ConvoItemError.PollFailed, { + type: 'error-recoverable', + key: ConvoItemError.PollFailed, + code: ConvoItemError.PollFailed, + retry: () => { + this.footerItems.delete(ConvoItemError.PollFailed) + this.pollingFailure = false + this.commit() + this.pollEvents() + }, + }) + this.commit() + } + } + + async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) { + // Ignore empty messages for now since they have no other purpose atm + if (!message.text.trim()) return + + logger.debug('Convo: send message', {}, logger.DebugContext.convo) + + const tempId = nanoid() + + this.pendingMessages.set(tempId, { + id: tempId, + message, + }) + this.commit() + + if (!this.isProcessingPendingMessages) { + this.processPendingMessages() + } + } + + async processPendingMessages() { + logger.debug( + `Convo: processing messages (${this.pendingMessages.size} remaining)`, + {}, + logger.DebugContext.convo, + ) + + const pendingMessage = Array.from(this.pendingMessages.values()).shift() + + /* + * If there are no pending messages, we're done. + */ + if (!pendingMessage) { + this.isProcessingPendingMessages = false + return + } + + try { + this.isProcessingPendingMessages = true + + // throw new Error('UNCOMMENT TO TEST RETRY') + const {id, message} = pendingMessage + + const response = await this.agent.api.chat.bsky.convo.sendMessage( + { + convoId: this.convoId, + message, + }, + { + encoding: 'application/json', + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const res = response.data + + /* + * Insert into `newMessages` as soon as we have a real ID. That way, when + * we get an event log back, we can replace in situ. + */ + this.newMessages.set(res.id, { + ...res, + $type: 'chat.bsky.convo.defs#messageView', + sender: this.sender, + }) + this.pendingMessages.delete(id) + + await this.processPendingMessages() + + this.commit() + } catch (e) { + this.footerItems.set('pending-retry', { + type: 'pending-retry', + key: 'pending-retry', + retry: this.batchRetryPendingMessages.bind(this), + }) + this.commit() + } + } + + async batchRetryPendingMessages() { + logger.debug( + `Convo: retrying ${this.pendingMessages.size} pending messages`, + {}, + logger.DebugContext.convo, + ) + + this.footerItems.delete('pending-retry') + this.commit() + + try { + const messageArray = Array.from(this.pendingMessages.values()) + const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch( + { + items: messageArray.map(({message}) => ({ + convoId: this.convoId, + message, + })), + }, + { + encoding: 'application/json', + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const {items} = data + + /* + * Insert into `newMessages` as soon as we have a real ID. That way, when + * we get an event log back, we can replace in situ. + */ + for (const item of items) { + this.newMessages.set(item.id, { + ...item, + $type: 'chat.bsky.convo.defs#messageView', + sender: this.convo?.members.find( + m => m.did === this.__tempFromUserDid, + ), + }) + } + + for (const pendingMessage of messageArray) { + this.pendingMessages.delete(pendingMessage.id) + } + + this.commit() + } catch (e) { + this.footerItems.set('pending-retry', { + type: 'pending-retry', + key: 'pending-retry', + retry: this.batchRetryPendingMessages.bind(this), + }) + this.commit() + } + } + + async deleteMessage(messageId: string) { + logger.debug('Convo: delete message', {}, logger.DebugContext.convo) + + this.deletedMessages.add(messageId) + this.commit() + + try { + await this.agent.api.chat.bsky.convo.deleteMessageForSelf( + { + convoId: this.convoId, + messageId, + }, + { + encoding: 'application/json', + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + } catch (e) { + this.deletedMessages.delete(messageId) + this.commit() + throw e + } + } + + /* + * Items in reverse order, since FlatList inverts + */ + getItems(): ConvoItem[] { + const items: ConvoItem[] = [] + + this.headerItems.forEach(item => { + items.push(item) + }) + + this.pastMessages.forEach(m => { + if (ChatBskyConvoDefs.isMessageView(m)) { + items.unshift({ + type: 'message', + key: m.id, + message: m, + nextMessage: null, + }) + } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { + items.unshift({ + type: 'deleted-message', + key: m.id, + message: m, + nextMessage: null, + }) + } + }) + + this.newMessages.forEach(m => { + if (ChatBskyConvoDefs.isMessageView(m)) { + items.push({ + type: 'message', + key: m.id, + message: m, + nextMessage: null, + }) + } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { + items.push({ + type: 'deleted-message', + key: m.id, + message: m, + nextMessage: null, + }) + } + }) + + this.pendingMessages.forEach(m => { + items.push({ + type: 'pending-message', + key: m.id, + message: { + ...m.message, + id: nanoid(), + rev: '__fake__', + sentAt: new Date().toISOString(), + sender: this.sender, + }, + nextMessage: null, + }) + }) + + this.footerItems.forEach(item => { + items.push(item) + }) + + return items + .filter(item => { + if (isConvoItemMessage(item)) { + return !this.deletedMessages.has(item.message.id) + } + return true + }) + .map((item, i, arr) => { + let nextMessage = null + const isMessage = isConvoItemMessage(item) + + if (isMessage) { + if ( + isMessage && + (ChatBskyConvoDefs.isMessageView(item.message) || + ChatBskyConvoDefs.isDeletedMessageView(item.message)) + ) { + const next = arr[i + 1] + + if ( + isConvoItemMessage(next) && + next && + (ChatBskyConvoDefs.isMessageView(next.message) || + ChatBskyConvoDefs.isDeletedMessageView(next.message)) + ) { + nextMessage = next.message + } + } + + return { + ...item, + nextMessage, + } + } + + return item + }) + } +} diff --git a/src/state/messages/index.tsx b/src/state/messages/index.tsx new file mode 100644 index 000000000..22c4242e2 --- /dev/null +++ b/src/state/messages/index.tsx @@ -0,0 +1,48 @@ +import React, {useContext, useState, useSyncExternalStore} from 'react' +import {BskyAgent} from '@atproto-labs/api' +import {useFocusEffect} from '@react-navigation/native' + +import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo' +import {useAgent} from '#/state/session' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' + +const ChatContext = React.createContext<ConvoState | null>(null) + +export function useChat() { + const ctx = useContext(ChatContext) + if (!ctx) { + throw new Error('useChat must be used within a ChatProvider') + } + return ctx +} + +export function ChatProvider({ + children, + convoId, +}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) { + const {serviceUrl} = useDmServiceUrlStorage() + const {getAgent} = useAgent() + const [convo] = useState( + () => + new Convo({ + convoId, + agent: new BskyAgent({ + service: serviceUrl, + }), + __tempFromUserDid: getAgent().session?.did!, + }), + ) + const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) + + useFocusEffect( + React.useCallback(() => { + convo.resume() + + return () => { + convo.background() + } + }, [convo]), + ) + + return <ChatContext.Provider value={service}>{children}</ChatContext.Provider> +} diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index f57172d2f..5fe0f9bd0 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -1,11 +1,11 @@ import EventEmitter from 'eventemitter3' + +import BroadcastChannel from '#/lib/broadcast' import {logger} from '#/logger' -import {defaults, Schema} from '#/state/persisted/schema' import {migrate} from '#/state/persisted/legacy' +import {defaults, Schema} from '#/state/persisted/schema' import * as store from '#/state/persisted/store' -import BroadcastChannel from '#/lib/broadcast' - -export type {Schema, PersistedAccount} from '#/state/persisted/schema' +export type {PersistedAccount, Schema} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema' const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL') diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index f090365a3..77a79b78e 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -4,7 +4,10 @@ import {deviceLocales, prefersReducedMotion} from '#/platform/detection' const externalEmbedOptions = ['show', 'hide'] as const -// only data needed for rendering account page +/** + * A account persisted to storage. Stored in the `accounts[]` array. Contains + * base account info and access tokens. + */ const accountSchema = z.object({ service: z.string(), did: z.string(), @@ -19,12 +22,26 @@ const accountSchema = z.object({ }) export type PersistedAccount = z.infer<typeof accountSchema> +/** + * The current account. Stored in the `currentAccount` field. + * + * In previous versions, this included tokens and other info. Now, it's used + * only to reference the `did` field, and all other fields are marked as + * optional. They should be considered deprecated and not used, but are kept + * here for backwards compat. + */ +const currentAccountSchema = accountSchema.extend({ + service: z.string().optional(), + handle: z.string().optional(), +}) +export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema> + export const schema = z.object({ colorMode: z.enum(['system', 'light', 'dark']), darkTheme: z.enum(['dim', 'dark']).optional(), session: z.object({ accounts: z.array(accountSchema), - currentAccount: accountSchema.optional(), + currentAccount: currentAccountSchema.optional(), }), reminders: z.object({ lastEmailConfirm: z.string().optional(), @@ -63,6 +80,7 @@ export const schema = z.object({ pdsAddressHistory: z.array(z.string()).optional(), disableHaptics: z.boolean().optional(), disableAutoplay: z.boolean().optional(), + kawaii: z.boolean().optional(), }) export type Schema = z.infer<typeof schema> @@ -100,4 +118,5 @@ export const defaults: Schema = { pdsAddressHistory: [], disableHaptics: false, disableAutoplay: prefersReducedMotion, + kawaii: false, } diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index 5c8fab2ad..5bca35452 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -1,11 +1,13 @@ import React from 'react' +import {DmServiceUrlProvider} from '#/screens/Messages/Temp/useDmServiceUrlStorage' import {Provider as AltTextRequiredProvider} from './alt-text-required' import {Provider as AutoplayProvider} from './autoplay' import {Provider as DisableHapticsProvider} from './disable-haptics' import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' import {Provider as HiddenPostsProvider} from './hidden-posts' import {Provider as InAppBrowserProvider} from './in-app-browser' +import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' export { @@ -30,7 +32,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { <HiddenPostsProvider> <InAppBrowserProvider> <DisableHapticsProvider> - <AutoplayProvider>{children}</AutoplayProvider> + <AutoplayProvider> + <DmServiceUrlProvider> + <KawaiiProvider>{children}</KawaiiProvider> + </DmServiceUrlProvider> + </AutoplayProvider> </DisableHapticsProvider> </InAppBrowserProvider> </HiddenPostsProvider> diff --git a/src/state/preferences/kawaii.tsx b/src/state/preferences/kawaii.tsx new file mode 100644 index 000000000..4aa95ef8b --- /dev/null +++ b/src/state/preferences/kawaii.tsx @@ -0,0 +1,50 @@ +import React from 'react' + +import {isWeb} from '#/platform/detection' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['kawaii'] + +const stateContext = React.createContext<StateContext>( + persisted.defaults.kawaii, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('kawaii')) + + const setStateWrapped = React.useCallback( + (kawaii: persisted.Schema['kawaii']) => { + setState(kawaii) + persisted.write('kawaii', kawaii) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('kawaii')) + }) + }, [setStateWrapped]) + + React.useEffect(() => { + // dumb and stupid but it's web only so just refresh the page if you want to change it + + if (isWeb) { + const kawaii = new URLSearchParams(window.location.search).get('kawaii') + switch (kawaii) { + case 'true': + setStateWrapped(true) + break + case 'false': + setStateWrapped(false) + break + } + } + }, [setStateWrapped]) + + return <stateContext.Provider value={state}>{children}</stateContext.Provider> +} + +export function useKawaiiMode() { + return React.useContext(stateContext) +} diff --git a/src/state/preferences/label-defs.tsx b/src/state/preferences/label-defs.tsx index d60f8ccb8..e24a1144a 100644 --- a/src/state/preferences/label-defs.tsx +++ b/src/state/preferences/label-defs.tsx @@ -1,5 +1,6 @@ import React from 'react' -import {InterpretedLabelValueDefinition, AppBskyLabelerDefs} from '@atproto/api' +import {AppBskyLabelerDefs, InterpretedLabelValueDefinition} from '@atproto/api' + import {useLabelDefinitionsQuery} from '../queries/preferences' interface StateContext { @@ -13,10 +14,7 @@ const stateContext = React.createContext<StateContext>({ }) export function Provider({children}: React.PropsWithChildren<{}>) { - const {labelDefs, labelers} = useLabelDefinitionsQuery() - - const state = {labelDefs, labelers} - + const state = useLabelDefinitionsQuery() return <stateContext.Provider value={state}>{children}</stateContext.Provider> } diff --git a/src/state/preferences/moderation-opts.tsx b/src/state/preferences/moderation-opts.tsx new file mode 100644 index 000000000..b0278d5e8 --- /dev/null +++ b/src/state/preferences/moderation-opts.tsx @@ -0,0 +1,61 @@ +import React, {createContext, useContext, useMemo} from 'react' +import {BSKY_LABELER_DID, ModerationOpts} from '@atproto/api' + +import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences' +import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' +import {useSession} from '#/state/session' +import {usePreferencesQuery} from '../queries/preferences' + +export const moderationOptsContext = createContext<ModerationOpts | undefined>( + undefined, +) + +// used in the moderation state devtool +export const moderationOptsOverrideContext = createContext< + ModerationOpts | undefined +>(undefined) + +export function useModerationOpts() { + return useContext(moderationOptsContext) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const override = useContext(moderationOptsOverrideContext) + const {currentAccount} = useSession() + const prefs = usePreferencesQuery() + const {labelDefs} = useLabelDefinitions() + const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs + + const userDid = currentAccount?.did + const moderationPrefs = prefs.data?.moderationPrefs + const value = useMemo<ModerationOpts | undefined>(() => { + if (override) { + return override + } + if (!moderationPrefs) { + return undefined + } + return { + userDid, + prefs: { + ...moderationPrefs, + labelers: moderationPrefs.labelers.length + ? moderationPrefs.labelers + : [ + { + did: BSKY_LABELER_DID, + labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, + }, + ], + hiddenPosts: hiddenPosts || [], + }, + labelDefs, + } + }, [override, userDid, labelDefs, moderationPrefs, hiddenPosts]) + + return ( + <moderationOptsContext.Provider value={value}> + {children} + </moderationOptsContext.Provider> + ) +} diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 98b5aa17e..8708a244b 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -6,7 +6,8 @@ import {isJustAMute} from '#/lib/moderation' import {logger} from '#/logger' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' -import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences' +import {useModerationOpts} from '../preferences/moderation-opts' +import {DEFAULT_LOGGED_OUT_PREFERENCES} from './preferences' const DEFAULT_MOD_OPTS = { userDid: undefined, @@ -23,7 +24,11 @@ export function useActorAutocompleteQuery( const moderationOpts = useModerationOpts() const {getAgent} = useAgent() - prefix = prefix.toLowerCase() + prefix = prefix.toLowerCase().trim() + if (prefix.endsWith('.')) { + // Going from "foo" to "foo." should not clear matches. + prefix = prefix.slice(0, -1) + } return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({ staleTime: STALE.MINUTES.ONE, diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts index e30528ca1..0635bf316 100644 --- a/src/state/queries/index.ts +++ b/src/state/queries/index.ts @@ -1,11 +1,3 @@ -import {BskyAgent} from '@atproto/api' - -import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' - -export const PUBLIC_BSKY_AGENT = new BskyAgent({ - service: PUBLIC_BSKY_SERVICE, -}) - export const STALE = { SECONDS: { FIFTEEN: 1e3 * 15, diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts new file mode 100644 index 000000000..9456861d2 --- /dev/null +++ b/src/state/queries/messages/conversation.ts @@ -0,0 +1,25 @@ +import {BskyAgent} from '@atproto-labs/api' +import {useQuery} from '@tanstack/react-query' + +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {useHeaders} from './temp-headers' + +const RQKEY_ROOT = 'convo' +export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId] + +export function useConvoQuery(convoId: string) { + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useQuery({ + queryKey: RQKEY(convoId), + queryFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.getConvo( + {convoId}, + {headers}, + ) + return data.convo + }, + }) +} diff --git a/src/state/queries/messages/get-convo-for-members.ts b/src/state/queries/messages/get-convo-for-members.ts new file mode 100644 index 000000000..0a657c07e --- /dev/null +++ b/src/state/queries/messages/get-convo-for-members.ts @@ -0,0 +1,39 @@ +import {BskyAgent, ChatBskyConvoGetConvoForMembers} from '@atproto-labs/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {RQKEY as CONVO_KEY} from './conversation' +import {useHeaders} from './temp-headers' + +export function useGetConvoForMembers({ + onSuccess, + onError, +}: { + onSuccess?: (data: ChatBskyConvoGetConvoForMembers.OutputSchema) => void + onError?: (error: Error) => void +}) { + const queryClient = useQueryClient() + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useMutation({ + mutationFn: async (members: string[]) => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.getConvoForMembers( + {members: members}, + {headers}, + ) + + return data + }, + onSuccess: data => { + queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/messages/leave-conversation.ts b/src/state/queries/messages/leave-conversation.ts new file mode 100644 index 000000000..0dd67fa0b --- /dev/null +++ b/src/state/queries/messages/leave-conversation.ts @@ -0,0 +1,68 @@ +import { + BskyAgent, + ChatBskyConvoLeaveConvo, + ChatBskyConvoListConvos, +} from '@atproto-labs/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {RQKEY as CONVO_LIST_KEY} from './list-converations' +import {useHeaders} from './temp-headers' + +export function useLeaveConvo( + convoId: string, + { + onSuccess, + onError, + }: { + onSuccess?: (data: ChatBskyConvoLeaveConvo.OutputSchema) => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useMutation({ + mutationFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.leaveConvo( + {convoId}, + {headers, encoding: 'application/json'}, + ) + + return data + }, + onMutate: () => { + queryClient.setQueryData( + CONVO_LIST_KEY, + (old?: { + pageParams: Array<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + console.log('old', old) + if (!old) return old + return { + ...old, + pages: old.pages.map(page => { + return { + ...page, + convos: page.convos.filter(convo => convo.id !== convoId), + } + }), + } + }, + ) + }, + onSuccess: data => { + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/messages/list-converations.ts b/src/state/queries/messages/list-converations.ts new file mode 100644 index 000000000..1e4ecb6d7 --- /dev/null +++ b/src/state/queries/messages/list-converations.ts @@ -0,0 +1,29 @@ +import {BskyAgent} from '@atproto-labs/api' +import {useInfiniteQuery} from '@tanstack/react-query' + +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {useHeaders} from './temp-headers' + +export const RQKEY = ['convo-list'] +type RQPageParam = string | undefined + +export function useListConvos({refetchInterval}: {refetchInterval: number}) { + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useInfiniteQuery({ + queryKey: RQKEY, + queryFn: async ({pageParam}) => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.listConvos( + {cursor: pageParam}, + {headers}, + ) + + return data + }, + initialPageParam: undefined as RQPageParam, + getNextPageParam: lastPage => lastPage.cursor, + refetchInterval, + }) +} diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts new file mode 100644 index 000000000..4840c65ad --- /dev/null +++ b/src/state/queries/messages/mute-conversation.ts @@ -0,0 +1,84 @@ +import { + BskyAgent, + ChatBskyConvoMuteConvo, + ChatBskyConvoUnmuteConvo, +} from '@atproto-labs/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {RQKEY as CONVO_KEY} from './conversation' +import {RQKEY as CONVO_LIST_KEY} from './list-converations' +import {useHeaders} from './temp-headers' + +export function useMuteConvo( + convoId: string, + { + onSuccess, + onError, + }: { + onSuccess?: (data: ChatBskyConvoMuteConvo.OutputSchema) => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useMutation({ + mutationFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.muteConvo( + {convoId}, + {headers, encoding: 'application/json'}, + ) + + return data + }, + onSuccess: data => { + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)}) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + onError?.(error) + }, + }) +} + +export function useUnmuteConvo( + convoId: string, + { + onSuccess, + onError, + }: { + onSuccess?: (data: ChatBskyConvoUnmuteConvo.OutputSchema) => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useMutation({ + mutationFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.unmuteConvo( + {convoId}, + {headers, encoding: 'application/json'}, + ) + + return data + }, + onSuccess: data => { + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)}) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/messages/temp-headers.ts b/src/state/queries/messages/temp-headers.ts new file mode 100644 index 000000000..9e46e8a61 --- /dev/null +++ b/src/state/queries/messages/temp-headers.ts @@ -0,0 +1,11 @@ +import {useSession} from '#/state/session' + +// toy auth +export const useHeaders = () => { + const {currentAccount} = useSession() + return { + get Authorization() { + return currentAccount!.did + }, + } +} diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 1f2199901..80e5a4c47 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -28,8 +28,8 @@ import { import {useMutedThreads} from '#/state/muted-threads' import {useAgent} from '#/state/session' +import {useModerationOpts} from '../../preferences/moderation-opts' import {STALE} from '..' -import {useModerationOpts} from '../preferences' import {embedViewRecordToPostView, getEmbeddedPost} from '../util' import {FeedPage} from './types' import {useUnreadNotificationsApi} from './unread' diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index 1c569e2a0..80333b524 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -13,7 +13,7 @@ import {logger} from '#/logger' import {isNative} from '#/platform/detection' import {useMutedThreads} from '#/state/muted-threads' import {useAgent, useSession} from '#/state/session' -import {useModerationOpts} from '../preferences' +import {useModerationOpts} from '../../preferences/moderation-opts' import {truncateAndInvalidate} from '../util' import {RQKEY as RQKEY_NOTIFS} from './feed' import {CachedFeedPage, FeedPage} from './types' diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 747dba02e..827f8a2a8 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -15,6 +15,7 @@ import { } from '@tanstack/react-query' import {HomeFeedAPI} from '#/lib/api/feed/home' +import {aggregateUserInterests} from '#/lib/api/feed/utils' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {logger} from '#/logger' import {STALE} from '#/state/queries' @@ -31,7 +32,8 @@ import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip' import {BSKY_FEED_OWNER_DIDS} from 'lib/constants' import {KnownError} from '#/view/com/posts/FeedErrorMessage' import {useFeedTuners} from '../preferences/feed-tuners' -import {useModerationOpts} from './preferences' +import {useModerationOpts} from '../preferences/moderation-opts' +import {usePreferencesQuery} from './preferences' import {embedViewRecordToPostView, getEmbeddedPost} from './util' type ActorDid = string @@ -102,8 +104,11 @@ export function usePostFeedQuery( ) { const feedTuners = useFeedTuners(feedDesc) const moderationOpts = useModerationOpts() + const {data: preferences} = usePreferencesQuery() + const enabled = + opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences) + const userInterests = aggregateUserInterests(preferences) const {getAgent} = useAgent() - const enabled = opts?.enabled !== false && Boolean(moderationOpts) const lastRun = useRef<{ data: InfiniteData<FeedPageUnselected> args: typeof selectArgs @@ -141,6 +146,7 @@ export function usePostFeedQuery( feedDesc, feedParams: params || {}, feedTuners, + userInterests, // Not in the query key because they don't change. getAgent, }), cursor: undefined, @@ -371,11 +377,13 @@ function createApi({ feedDesc, feedParams, feedTuners, + userInterests, getAgent, }: { feedDesc: FeedDescriptor feedParams: FeedParams feedTuners: FeedTunerFn[] + userInterests?: string getAgent: () => BskyAgent }) { if (feedDesc === 'home') { @@ -384,9 +392,10 @@ function createApi({ getAgent, feedParams, feedTuners, + userInterests, }) } else { - return new HomeFeedAPI({getAgent}) + return new HomeFeedAPI({getAgent, userInterests}) } } else if (feedDesc === 'following') { return new FollowingFeedAPI({getAgent}) @@ -401,6 +410,7 @@ function createApi({ return new CustomFeedAPI({ getAgent, feedParams: {feed}, + userInterests, }) } else if (feedDesc.startsWith('list')) { const [_, list] = feedDesc.split('|') diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 06e47391f..f51eaac2a 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -1,28 +1,24 @@ -import {createContext, useContext, useMemo} from 'react' import { AppBskyActorDefs, - BSKY_LABELER_DID, BskyFeedViewPreference, LabelPreference, - ModerationOpts, } from '@atproto/api' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {track} from '#/lib/analytics/analytics' +import {replaceEqualDeep} from '#/lib/functions' import {getAge} from '#/lib/strings/time' -import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences' import {STALE} from '#/state/queries' import { DEFAULT_HOME_FEED_PREFS, DEFAULT_LOGGED_OUT_PREFERENCES, DEFAULT_THREAD_VIEW_PREFS, } from '#/state/queries/preferences/const' -import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' import { ThreadViewPreferences, UsePreferencesQueryResponse, } from '#/state/queries/preferences/types' -import {useAgent, useSession} from '#/state/session' +import {useAgent} from '#/state/session' import {saveLabelers} from '#/state/session/agent-config' export * from '#/state/queries/preferences/const' @@ -36,7 +32,7 @@ export function usePreferencesQuery() { const {getAgent} = useAgent() return useQuery({ staleTime: STALE.SECONDS.FIFTEEN, - structuralSharing: true, + structuralSharing: replaceEqualDeep, refetchOnWindowFocus: true, queryKey: preferencesQueryKey, queryFn: async () => { @@ -79,44 +75,6 @@ export function usePreferencesQuery() { }) } -// used in the moderation state devtool -export const moderationOptsOverrideContext = createContext< - ModerationOpts | undefined ->(undefined) - -export function useModerationOpts() { - const override = useContext(moderationOptsOverrideContext) - const {currentAccount} = useSession() - const prefs = usePreferencesQuery() - const {labelDefs} = useLabelDefinitions() - const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs - const opts = useMemo<ModerationOpts | undefined>(() => { - if (override) { - return override - } - if (!prefs.data) { - return - } - return { - userDid: currentAccount?.did, - prefs: { - ...prefs.data.moderationPrefs, - labelers: prefs.data.moderationPrefs.labelers.length - ? prefs.data.moderationPrefs.labelers - : [ - { - did: BSKY_LABELER_DID, - labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, - }, - ], - hiddenPosts: hiddenPosts || [], - }, - labelDefs, - } - }, [override, currentAccount, labelDefs, prefs.data, hiddenPosts]) - return opts -} - export function useClearPreferencesMutation() { const queryClient = useQueryClient() const {getAgent} = useAgent() diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 936912ab3..7740b1977 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -12,9 +12,15 @@ import { useQuery, } from '@tanstack/react-query' +import { + aggregateUserInterests, + createBskyTopicsHeader, +} from '#/lib/api/feed/utils' +import {getContentLanguages} from '#/state/preferences/languages' import {STALE} from '#/state/queries' -import {useModerationOpts} from '#/state/queries/preferences' +import {usePreferencesQuery} from '#/state/queries/preferences' import {useAgent, useSession} from '#/state/session' +import {useModerationOpts} from '../preferences/moderation-opts' const suggestedFollowsQueryKeyRoot = 'suggested-follows' const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot] @@ -29,6 +35,7 @@ export function useSuggestedFollowsQuery() { const {currentAccount} = useSession() const {getAgent} = useAgent() const moderationOpts = useModerationOpts() + const {data: preferences} = usePreferencesQuery() return useInfiniteQuery< AppBskyActorGetSuggestions.OutputSchema, @@ -37,14 +44,23 @@ export function useSuggestedFollowsQuery() { QueryKey, string | undefined >({ - enabled: !!moderationOpts, + enabled: !!moderationOpts && !!preferences, staleTime: STALE.HOURS.ONE, queryKey: suggestedFollowsQueryKey, queryFn: async ({pageParam}) => { - const res = await getAgent().app.bsky.actor.getSuggestions({ - limit: 25, - cursor: pageParam, - }) + const contentLangs = getContentLanguages().join(',') + const res = await getAgent().app.bsky.actor.getSuggestions( + { + limit: 25, + cursor: pageParam, + }, + { + headers: { + ...createBskyTopicsHeader(aggregateUserInterests(preferences)), + 'Accept-Language': contentLangs, + }, + }, + ) res.data.actors = res.data.actors .filter( diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index e45aa031f..276e3b97b 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -1,225 +1,199 @@ import React from 'react' -import { - AtpPersistSessionHandler, - BSKY_LABELER_DID, - BskyAgent, -} from '@atproto/api' -import {jwtDecode} from 'jwt-decode' +import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api' import {track} from '#/lib/analytics/analytics' import {networkRetry} from '#/lib/async/retry' -import {IS_TEST_USER} from '#/lib/constants' -import {logEvent, LogEvents, tryFetchGates} from '#/lib/statsig/statsig' -import {hasProp} from '#/lib/type-guards' +import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {logEvent, tryFetchGates} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import * as persisted from '#/state/persisted' -import {PUBLIC_BSKY_AGENT} from '#/state/queries' import {useCloseAllActiveElements} from '#/state/util' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {IS_DEV} from '#/env' import {emitSessionDropped} from '../events' -import {readLabelers} from './agent-config' - -let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT - -function __getAgent() { - return __globalAgent -} - -export function useAgent() { - return React.useMemo(() => ({getAgent: __getAgent}), []) -} +import { + agentToSessionAccount, + configureModerationForAccount, + configureModerationForGuest, + createAgentAndCreateAccount, + createAgentAndLogin, + isSessionDeactivated, + isSessionExpired, +} from './util' + +export type {SessionAccount} from '#/state/session/types' +import { + SessionAccount, + SessionApiContext, + SessionStateContext, +} from '#/state/session/types' -export type SessionAccount = persisted.PersistedAccount +export {isSessionDeactivated} -export type SessionState = { - isInitialLoad: boolean - isSwitchingAccounts: boolean - accounts: SessionAccount[] - currentAccount: SessionAccount | undefined -} -export type StateContext = SessionState & { - hasSession: boolean -} -export type ApiContext = { - createAccount: (props: { - service: string - email: string - password: string - handle: string - inviteCode?: string - verificationPhone?: string - verificationCode?: string - }) => Promise<void> - login: ( - props: { - service: string - identifier: string - password: string - authFactorToken?: string | undefined - }, - logContext: LogEvents['account:loggedIn']['logContext'], - ) => Promise<void> - /** - * A full logout. Clears the `currentAccount` from session, AND removes - * access tokens from all accounts, so that returning as any user will - * require a full login. - */ - logout: ( - logContext: LogEvents['account:loggedOut']['logContext'], - ) => Promise<void> - /** - * A partial logout. Clears the `currentAccount` from session, but DOES NOT - * clear access tokens from accounts, allowing the user to return to their - * other accounts without logging in. - * - * Used when adding a new account, deleting an account. - */ - clearCurrentAccount: () => void - initSession: (account: SessionAccount) => Promise<void> - resumeSession: (account?: SessionAccount) => Promise<void> - removeAccount: (account: SessionAccount) => void - selectAccount: ( - account: SessionAccount, - logContext: LogEvents['account:loggedIn']['logContext'], - ) => Promise<void> - updateCurrentAccount: ( - account: Partial< - Pick< - SessionAccount, - 'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor' - > - >, - ) => void -} +const PUBLIC_BSKY_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE}) +configureModerationForGuest() -const StateContext = React.createContext<StateContext>({ - isInitialLoad: true, - isSwitchingAccounts: false, +const StateContext = React.createContext<SessionStateContext>({ accounts: [], currentAccount: undefined, hasSession: false, }) -const ApiContext = React.createContext<ApiContext>({ +const ApiContext = React.createContext<SessionApiContext>({ createAccount: async () => {}, login: async () => {}, logout: async () => {}, initSession: async () => {}, - resumeSession: async () => {}, removeAccount: () => {}, - selectAccount: async () => {}, updateCurrentAccount: () => {}, clearCurrentAccount: () => {}, }) -function createPersistSessionHandler( - agent: BskyAgent, - account: SessionAccount, - persistSessionCallback: (props: { - expired: boolean - refreshedAccount: SessionAccount - }) => void, - { - networkErrorCallback, - }: { - networkErrorCallback?: () => void - } = {}, -): AtpPersistSessionHandler { - return function persistSession(event, session) { - const expired = event === 'expired' || event === 'create-failed' - - if (event === 'network-error') { - logger.warn(`session: persistSessionHandler received network-error event`) - networkErrorCallback?.() - return - } - - const refreshedAccount: SessionAccount = { - service: account.service, - did: session?.did || account.did, - handle: session?.handle || account.handle, - email: session?.email || account.email, - emailConfirmed: session?.emailConfirmed || account.emailConfirmed, - deactivated: isSessionDeactivated(session?.accessJwt), - pdsUrl: agent.pdsUrl?.toString(), - - /* - * Tokens are undefined if the session expires, or if creation fails for - * any reason e.g. tokens are invalid, network error, etc. - */ - refreshJwt: session?.refreshJwt, - accessJwt: session?.accessJwt, - } +let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT - logger.debug(`session: persistSession`, { - event, - deactivated: refreshedAccount.deactivated, - }) +function __getAgent() { + return __globalAgent +} - if (expired) { - logger.warn(`session: expired`) - emitSessionDropped() - } +type AgentState = { + readonly agent: BskyAgent + readonly did: string | undefined +} - /* - * If the session expired, or it was successfully created/updated, we want - * to update/persist the data. - * - * If the session creation failed, it could be a network error, or it could - * be more serious like an invalid token(s). We can't differentiate, so in - * order to allow the user to get a fresh token (if they need it), we need - * to persist this data and wipe their tokens, effectively logging them - * out. - */ - persistSessionCallback({ - expired, - refreshedAccount, - }) - } +type State = { + accounts: SessionStateContext['accounts'] + currentAgentState: AgentState + needsPersist: boolean } export function Provider({children}: React.PropsWithChildren<{}>) { - const isDirty = React.useRef(false) - const [state, setState] = React.useState<SessionState>({ - isInitialLoad: true, - isSwitchingAccounts: false, + const [state, setState] = React.useState<State>(() => ({ accounts: persisted.get('session').accounts, - currentAccount: undefined, // assume logged out to start - }) - - const setStateAndPersist = React.useCallback( - (fn: (prev: SessionState) => SessionState) => { - isDirty.current = true - setState(fn) + currentAgentState: { + agent: PUBLIC_BSKY_AGENT, + did: undefined, // assume logged out to start }, - [setState], - ) + needsPersist: false, + })) - const upsertAccount = React.useCallback( - (account: SessionAccount, expired = false) => { - setStateAndPersist(s => { + const clearCurrentAccount = React.useCallback(() => { + logger.warn(`session: clear current account`) + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => ({ + accounts: s.accounts, + currentAgentState: { + agent: PUBLIC_BSKY_AGENT, + did: undefined, + }, + needsPersist: true, + })) + }, [setState]) + + const onAgentSessionChange = React.useCallback( + ( + agent: BskyAgent, + account: SessionAccount, + event: AtpSessionEvent, + session: AtpSessionData | undefined, + ) => { + const expired = event === 'expired' || event === 'create-failed' + + if (event === 'network-error') { + logger.warn( + `session: persistSessionHandler received network-error event`, + ) + logger.warn(`session: clear current account`) + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => ({ + accounts: s.accounts, + currentAgentState: { + agent: PUBLIC_BSKY_AGENT, + did: undefined, + }, + needsPersist: true, + })) + return + } + + // TODO: use agentToSessionAccount for this too. + const refreshedAccount: SessionAccount = { + service: account.service, + did: session?.did ?? account.did, + handle: session?.handle ?? account.handle, + email: session?.email ?? account.email, + emailConfirmed: session?.emailConfirmed ?? account.emailConfirmed, + emailAuthFactor: session?.emailAuthFactor ?? account.emailAuthFactor, + deactivated: isSessionDeactivated(session?.accessJwt), + pdsUrl: agent.pdsUrl?.toString(), + + /* + * Tokens are undefined if the session expires, or if creation fails for + * any reason e.g. tokens are invalid, network error, etc. + */ + refreshJwt: session?.refreshJwt, + accessJwt: session?.accessJwt, + } + + logger.debug(`session: persistSession`, { + event, + deactivated: refreshedAccount.deactivated, + }) + + if (expired) { + logger.warn(`session: expired`) + emitSessionDropped() + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => ({ + accounts: s.accounts, + currentAgentState: { + agent: PUBLIC_BSKY_AGENT, + did: undefined, + }, + needsPersist: true, + })) + } + + /* + * If the session expired, or it was successfully created/updated, we want + * to update/persist the data. + * + * If the session creation failed, it could be a network error, or it could + * be more serious like an invalid token(s). We can't differentiate, so in + * order to allow the user to get a fresh token (if they need it), we need + * to persist this data and wipe their tokens, effectively logging them + * out. + */ + setState(s => { + const existingAccount = s.accounts.find( + a => a.did === refreshedAccount.did, + ) + if ( + !expired && + existingAccount && + refreshedAccount && + JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount) + ) { + // Fast path without a state update. + return s + } return { - ...s, - currentAccount: expired ? undefined : account, - accounts: [account, ...s.accounts.filter(a => a.did !== account.did)], + accounts: [ + refreshedAccount, + ...s.accounts.filter(a => a.did !== refreshedAccount.did), + ], + currentAgentState: s.currentAgentState, + needsPersist: true, } }) }, - [setStateAndPersist], + [], ) - const clearCurrentAccount = React.useCallback(() => { - logger.warn(`session: clear current account`) - __globalAgent = PUBLIC_BSKY_AGENT - setStateAndPersist(s => ({ - ...s, - currentAccount: undefined, - })) - }, [setStateAndPersist]) - - const createAccount = React.useCallback<ApiContext['createAccount']>( + const createAccount = React.useCallback<SessionApiContext['createAccount']>( async ({ service, email, @@ -228,157 +202,109 @@ export function Provider({children}: React.PropsWithChildren<{}>) { inviteCode, verificationPhone, verificationCode, - }: any) => { + }) => { logger.info(`session: creating account`) track('Try Create Account') logEvent('account:create:begin', {}) - - const agent = new BskyAgent({service}) - - await agent.createAccount({ - handle, - password, - email, - inviteCode, - verificationPhone, - verificationCode, - }) - - if (!agent.session) { - throw new Error(`session: createAccount failed to establish a session`) - } - const fetchingGates = tryFetchGates( - agent.session.did, - 'prefer-fresh-gates', + const {agent, account, fetchingGates} = await createAgentAndCreateAccount( + { + service, + email, + password, + handle, + inviteCode, + verificationPhone, + verificationCode, + }, ) - const deactivated = isSessionDeactivated(agent.session.accessJwt) - if (!deactivated) { - /*dont await*/ agent.upsertProfile(_existing => { - return { - displayName: '', - - // HACKFIX - // creating a bunch of identical profile objects is breaking the relay - // tossing this unspecced field onto it to reduce the size of the problem - // -prf - createdAt: new Date().toISOString(), - } - }) - } - - const account: SessionAccount = { - service: agent.service.toString(), - did: agent.session.did, - handle: agent.session.handle, - email: agent.session.email!, // TODO this is always defined? - emailConfirmed: false, - refreshJwt: agent.session.refreshJwt, - accessJwt: agent.session.accessJwt, - deactivated, - pdsUrl: agent.pdsUrl?.toString(), - } - - await configureModeration(agent, account) - - agent.setPersistSessionHandler( - createPersistSessionHandler( - agent, - account, - ({expired, refreshedAccount}) => { - upsertAccount(refreshedAccount, expired) - }, - {networkErrorCallback: clearCurrentAccount}, - ), - ) + agent.setPersistSessionHandler((event, session) => { + onAgentSessionChange(agent, account, event, session) + }) __globalAgent = agent await fetchingGates - upsertAccount(account) + setState(s => { + return { + accounts: [account, ...s.accounts.filter(a => a.did !== account.did)], + currentAgentState: { + did: account.did, + agent: agent, + }, + needsPersist: true, + } + }) logger.debug(`session: created account`, {}, logger.DebugContext.session) track('Create Account') logEvent('account:create:success', {}) }, - [upsertAccount, clearCurrentAccount], + [onAgentSessionChange], ) - const login = React.useCallback<ApiContext['login']>( + const login = React.useCallback<SessionApiContext['login']>( async ({service, identifier, password, authFactorToken}, logContext) => { logger.debug(`session: login`, {}, logger.DebugContext.session) + const {agent, account, fetchingGates} = await createAgentAndLogin({ + service, + identifier, + password, + authFactorToken, + }) - const agent = new BskyAgent({service}) - - await agent.login({identifier, password, authFactorToken}) - - if (!agent.session) { - throw new Error(`session: login failed to establish a session`) - } - const fetchingGates = tryFetchGates( - agent.session.did, - 'prefer-fresh-gates', - ) - - const account: SessionAccount = { - service: agent.service.toString(), - did: agent.session.did, - handle: agent.session.handle, - email: agent.session.email, - emailConfirmed: agent.session.emailConfirmed || false, - emailAuthFactor: agent.session.emailAuthFactor, - refreshJwt: agent.session.refreshJwt, - accessJwt: agent.session.accessJwt, - deactivated: isSessionDeactivated(agent.session.accessJwt), - pdsUrl: agent.pdsUrl?.toString(), - } - - await configureModeration(agent, account) - - agent.setPersistSessionHandler( - createPersistSessionHandler( - agent, - account, - ({expired, refreshedAccount}) => { - upsertAccount(refreshedAccount, expired) - }, - {networkErrorCallback: clearCurrentAccount}, - ), - ) + agent.setPersistSessionHandler((event, session) => { + onAgentSessionChange(agent, account, event, session) + }) __globalAgent = agent // @ts-ignore if (IS_DEV && isWeb) window.agent = agent await fetchingGates - upsertAccount(account) + setState(s => { + return { + accounts: [account, ...s.accounts.filter(a => a.did !== account.did)], + currentAgentState: { + did: account.did, + agent: agent, + }, + needsPersist: true, + } + }) logger.debug(`session: logged in`, {}, logger.DebugContext.session) track('Sign In', {resumedSession: false}) logEvent('account:loggedIn', {logContext, withPassword: true}) }, - [upsertAccount, clearCurrentAccount], + [onAgentSessionChange], ) - const logout = React.useCallback<ApiContext['logout']>( + const logout = React.useCallback<SessionApiContext['logout']>( async logContext => { logger.debug(`session: logout`) - clearCurrentAccount() - setStateAndPersist(s => { + logger.warn(`session: clear current account`) + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => { return { - ...s, accounts: s.accounts.map(a => ({ ...a, refreshJwt: undefined, accessJwt: undefined, })), + currentAgentState: { + did: undefined, + agent: PUBLIC_BSKY_AGENT, + }, + needsPersist: true, } }) logEvent('account:loggedOut', {logContext}) }, - [clearCurrentAccount, setStateAndPersist], + [setState], ) - const initSession = React.useCallback<ApiContext['initSession']>( + const initSession = React.useCallback<SessionApiContext['initSession']>( async account => { logger.debug(`session: initSession`, {}, logger.DebugContext.session) const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency') @@ -390,55 +316,65 @@ export function Provider({children}: React.PropsWithChildren<{}>) { agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl) } - agent.setPersistSessionHandler( - createPersistSessionHandler( - agent, - account, - ({expired, refreshedAccount}) => { - upsertAccount(refreshedAccount, expired) - }, - {networkErrorCallback: clearCurrentAccount}, - ), - ) + agent.setPersistSessionHandler((event, session) => { + onAgentSessionChange(agent, account, event, session) + }) // @ts-ignore if (IS_DEV && isWeb) window.agent = agent - await configureModeration(agent, account) - - let canReusePrevSession = false - try { - if (account.accessJwt) { - const decoded = jwtDecode(account.accessJwt) - if (decoded.exp) { - const didExpire = Date.now() >= decoded.exp * 1000 - if (!didExpire) { - canReusePrevSession = true - } - } - } - } catch (e) { - logger.error(`session: could not decode jwt`) - } + await configureModerationForAccount(agent, account) + + const accountOrSessionDeactivated = + isSessionDeactivated(account.accessJwt) || account.deactivated const prevSession = { - accessJwt: account.accessJwt || '', - refreshJwt: account.refreshJwt || '', + accessJwt: account.accessJwt ?? '', + refreshJwt: account.refreshJwt ?? '', did: account.did, handle: account.handle, - deactivated: - isSessionDeactivated(account.accessJwt) || account.deactivated, } - if (canReusePrevSession) { + if (isSessionExpired(account)) { + logger.debug(`session: attempting to resume using previous session`) + + const freshAccount = await resumeSessionWithFreshAccount() + __globalAgent = agent + await fetchingGates + setState(s => { + return { + accounts: [ + freshAccount, + ...s.accounts.filter(a => a.did !== freshAccount.did), + ], + currentAgentState: { + did: freshAccount.did, + agent: agent, + }, + needsPersist: true, + } + }) + } else { logger.debug(`session: attempting to reuse previous session`) agent.session = prevSession __globalAgent = agent await fetchingGates - upsertAccount(account) + setState(s => { + return { + accounts: [ + account, + ...s.accounts.filter(a => a.did !== account.did), + ], + currentAgentState: { + did: account.did, + agent: agent, + }, + needsPersist: true, + } + }) - if (prevSession.deactivated) { + if (accountOrSessionDeactivated) { // don't attempt to resume // use will be taken to the deactivated screen logger.debug(`session: reusing session for deactivated account`) @@ -447,191 +383,112 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // Intentionally not awaited to unblock the UI: resumeSessionWithFreshAccount() - .then(freshAccount => { - if (JSON.stringify(account) !== JSON.stringify(freshAccount)) { - logger.info( - `session: reuse of previous session returned a fresh account, upserting`, - ) - upsertAccount(freshAccount) - } - }) - .catch(e => { - /* - * Note: `agent.persistSession` is also called when this fails, and - * we handle that failure via `createPersistSessionHandler` - */ - logger.info(`session: resumeSessionWithFreshAccount failed`, { - message: e, - }) - - __globalAgent = PUBLIC_BSKY_AGENT - }) - } else { - logger.debug(`session: attempting to resume using previous session`) - - try { - const freshAccount = await resumeSessionWithFreshAccount() - __globalAgent = agent - await fetchingGates - upsertAccount(freshAccount) - } catch (e) { - /* - * Note: `agent.persistSession` is also called when this fails, and - * we handle that failure via `createPersistSessionHandler` - */ - logger.info(`session: resumeSessionWithFreshAccount failed`, { - message: e, - }) - - __globalAgent = PUBLIC_BSKY_AGENT - } } async function resumeSessionWithFreshAccount(): Promise<SessionAccount> { logger.debug(`session: resumeSessionWithFreshAccount`) await networkRetry(1, () => agent.resumeSession(prevSession)) - + const sessionAccount = agentToSessionAccount(agent) /* * If `agent.resumeSession` fails above, it'll throw. This is just to * make TypeScript happy. */ - if (!agent.session) { + if (!sessionAccount) { throw new Error(`session: initSession failed to establish a session`) } - - // ensure changes in handle/email etc are captured on reload - return { - service: agent.service.toString(), - did: agent.session.did, - handle: agent.session.handle, - email: agent.session.email, - emailConfirmed: agent.session.emailConfirmed || false, - emailAuthFactor: agent.session.emailAuthFactor || false, - refreshJwt: agent.session.refreshJwt, - accessJwt: agent.session.accessJwt, - deactivated: isSessionDeactivated(agent.session.accessJwt), - pdsUrl: agent.pdsUrl?.toString(), - } + return sessionAccount } }, - [upsertAccount, clearCurrentAccount], + [onAgentSessionChange], ) - const resumeSession = React.useCallback<ApiContext['resumeSession']>( - async account => { - try { - if (account) { - await initSession(account) - } - } catch (e) { - logger.error(`session: resumeSession failed`, {message: e}) - } finally { - setState(s => ({ - ...s, - isInitialLoad: false, - })) - } - }, - [initSession], - ) - - const removeAccount = React.useCallback<ApiContext['removeAccount']>( + const removeAccount = React.useCallback<SessionApiContext['removeAccount']>( account => { - setStateAndPersist(s => { + setState(s => { return { - ...s, accounts: s.accounts.filter(a => a.did !== account.did), + currentAgentState: s.currentAgentState, + needsPersist: true, } }) }, - [setStateAndPersist], + [setState], ) const updateCurrentAccount = React.useCallback< - ApiContext['updateCurrentAccount'] + SessionApiContext['updateCurrentAccount'] >( account => { - setStateAndPersist(s => { - const currentAccount = s.currentAccount - + setState(s => { + const currentAccount = s.accounts.find( + a => a.did === s.currentAgentState.did, + ) // ignore, should never happen if (!currentAccount) return s const updatedAccount = { ...currentAccount, - handle: account.handle || currentAccount.handle, - email: account.email || currentAccount.email, + handle: account.handle ?? currentAccount.handle, + email: account.email ?? currentAccount.email, emailConfirmed: - account.emailConfirmed !== undefined - ? account.emailConfirmed - : currentAccount.emailConfirmed, + account.emailConfirmed ?? currentAccount.emailConfirmed, emailAuthFactor: - account.emailAuthFactor !== undefined - ? account.emailAuthFactor - : currentAccount.emailAuthFactor, + account.emailAuthFactor ?? currentAccount.emailAuthFactor, } return { - ...s, - currentAccount: updatedAccount, accounts: [ updatedAccount, ...s.accounts.filter(a => a.did !== currentAccount.did), ], + currentAgentState: s.currentAgentState, + needsPersist: true, } }) }, - [setStateAndPersist], - ) - - const selectAccount = React.useCallback<ApiContext['selectAccount']>( - async (account, logContext) => { - setState(s => ({...s, isSwitchingAccounts: true})) - try { - await initSession(account) - setState(s => ({...s, isSwitchingAccounts: false})) - logEvent('account:loggedIn', {logContext, withPassword: false}) - } catch (e) { - // reset this in case of error - setState(s => ({...s, isSwitchingAccounts: false})) - // but other listeners need a throw - throw e - } - }, - [setState, initSession], + [setState], ) React.useEffect(() => { - if (isDirty.current) { - isDirty.current = false + if (state.needsPersist) { + state.needsPersist = false persisted.write('session', { accounts: state.accounts, - currentAccount: state.currentAccount, + currentAccount: state.accounts.find( + a => a.did === state.currentAgentState.did, + ), }) } }, [state]) React.useEffect(() => { return persisted.onUpdate(() => { - const session = persisted.get('session') + const persistedSession = persisted.get('session') logger.debug(`session: persisted onUpdate`, {}) + setState(s => ({ + accounts: persistedSession.accounts, + currentAgentState: s.currentAgentState, + needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. + })) + + const selectedAccount = persistedSession.accounts.find( + a => a.did === persistedSession.currentAccount?.did, + ) - if (session.currentAccount && session.currentAccount.refreshJwt) { - if (session.currentAccount?.did !== state.currentAccount?.did) { + if (selectedAccount && selectedAccount.refreshJwt) { + if (selectedAccount.did !== state.currentAgentState.did) { logger.debug(`session: persisted onUpdate, switching accounts`, { from: { - did: state.currentAccount?.did, - handle: state.currentAccount?.handle, + did: state.currentAgentState.did, }, to: { - did: session.currentAccount.did, - handle: session.currentAccount.handle, + did: selectedAccount.did, }, }) - initSession(session.currentAccount) + initSession(selectedAccount) } else { logger.debug(`session: persisted onUpdate, updating session`, {}) @@ -641,9 +498,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) { * already persisted, and we'll get a loop between tabs. */ // @ts-ignore we checked for `refreshJwt` above - __globalAgent.session = session.currentAccount + __globalAgent.session = selectedAccount + // TODO: This needs a setState. } - } else if (!session.currentAccount && state.currentAccount) { + } else if (!selectedAccount && state.currentAgentState.did) { logger.debug( `session: persisted onUpdate, logging out`, {}, @@ -656,21 +514,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) { * handled by `persistSession` (which nukes this accounts tokens only), * or by a `logout` call which nukes all accounts tokens) */ - clearCurrentAccount() + logger.warn(`session: clear current account`) + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => ({ + accounts: s.accounts, + currentAgentState: { + did: undefined, + agent: PUBLIC_BSKY_AGENT, + }, + needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. + })) } - - setState(s => ({ - ...s, - accounts: session.accounts, - currentAccount: session.currentAccount, - })) }) - }, [state, setState, clearCurrentAccount, initSession]) + }, [state, setState, initSession]) const stateContext = React.useMemo( () => ({ - ...state, - hasSession: !!state.currentAccount, + accounts: state.accounts, + currentAccount: state.accounts.find( + a => a.did === state.currentAgentState.did, + ), + hasSession: !!state.currentAgentState.did, }), [state], ) @@ -681,9 +546,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { login, logout, initSession, - resumeSession, removeAccount, - selectAccount, updateCurrentAccount, clearCurrentAccount, }), @@ -692,9 +555,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { login, logout, initSession, - resumeSession, removeAccount, - selectAccount, updateCurrentAccount, clearCurrentAccount, ], @@ -707,28 +568,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) } -async function configureModeration(agent: BskyAgent, account: SessionAccount) { - if (IS_TEST_USER(account.handle)) { - const did = ( - await agent - .resolveHandle({handle: 'mod-authority.test'}) - .catch(_ => undefined) - )?.data.did - if (did) { - console.warn('USING TEST ENV MODERATION') - BskyAgent.configure({appLabelers: [did]}) - } - } else { - BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) - const labelerDids = await readLabelers(account.did).catch(_ => {}) - if (labelerDids) { - agent.configureLabelersHeader( - labelerDids.filter(did => did !== BSKY_LABELER_DID), - ) - } - } -} - export function useSession() { return React.useContext(StateContext) } @@ -755,12 +594,6 @@ export function useRequireAuth() { ) } -export function isSessionDeactivated(accessJwt: string | undefined) { - if (accessJwt) { - const sessData = jwtDecode(accessJwt) - return ( - hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated' - ) - } - return false +export function useAgent() { + return React.useMemo(() => ({getAgent: __getAgent}), []) } diff --git a/src/state/session/types.ts b/src/state/session/types.ts new file mode 100644 index 000000000..b3252f777 --- /dev/null +++ b/src/state/session/types.ts @@ -0,0 +1,56 @@ +import {LogEvents} from '#/lib/statsig/statsig' +import {PersistedAccount} from '#/state/persisted' + +export type SessionAccount = PersistedAccount + +export type SessionStateContext = { + accounts: SessionAccount[] + currentAccount: SessionAccount | undefined + hasSession: boolean +} +export type SessionApiContext = { + createAccount: (props: { + service: string + email: string + password: string + handle: string + inviteCode?: string + verificationPhone?: string + verificationCode?: string + }) => Promise<void> + login: ( + props: { + service: string + identifier: string + password: string + authFactorToken?: string | undefined + }, + logContext: LogEvents['account:loggedIn']['logContext'], + ) => Promise<void> + /** + * A full logout. Clears the `currentAccount` from session, AND removes + * access tokens from all accounts, so that returning as any user will + * require a full login. + */ + logout: ( + logContext: LogEvents['account:loggedOut']['logContext'], + ) => Promise<void> + /** + * A partial logout. Clears the `currentAccount` from session, but DOES NOT + * clear access tokens from accounts, allowing the user to return to their + * other accounts without logging in. + * + * Used when adding a new account, deleting an account. + */ + clearCurrentAccount: () => void + initSession: (account: SessionAccount) => Promise<void> + removeAccount: (account: SessionAccount) => void + updateCurrentAccount: ( + account: Partial< + Pick< + SessionAccount, + 'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor' + > + >, + ) => void +} diff --git a/src/state/session/util/index.ts b/src/state/session/util/index.ts new file mode 100644 index 000000000..e3e246f7b --- /dev/null +++ b/src/state/session/util/index.ts @@ -0,0 +1,177 @@ +import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api' +import {jwtDecode} from 'jwt-decode' + +import {IS_TEST_USER} from '#/lib/constants' +import {tryFetchGates} from '#/lib/statsig/statsig' +import {hasProp} from '#/lib/type-guards' +import {logger} from '#/logger' +import * as persisted from '#/state/persisted' +import {readLabelers} from '../agent-config' +import {SessionAccount, SessionApiContext} from '../types' + +export function isSessionDeactivated(accessJwt: string | undefined) { + if (accessJwt) { + const sessData = jwtDecode(accessJwt) + return ( + hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated' + ) + } + return false +} + +export function readLastActiveAccount() { + const {currentAccount, accounts} = persisted.get('session') + return accounts.find(a => a.did === currentAccount?.did) +} + +export function agentToSessionAccount( + agent: BskyAgent, +): SessionAccount | undefined { + if (!agent.session) return undefined + + return { + service: agent.service.toString(), + did: agent.session.did, + handle: agent.session.handle, + email: agent.session.email, + emailConfirmed: agent.session.emailConfirmed || false, + emailAuthFactor: agent.session.emailAuthFactor || false, + refreshJwt: agent.session.refreshJwt, + accessJwt: agent.session.accessJwt, + deactivated: isSessionDeactivated(agent.session.accessJwt), + pdsUrl: agent.pdsUrl?.toString(), + } +} + +export function configureModerationForGuest() { + switchToBskyAppLabeler() +} + +export async function configureModerationForAccount( + agent: BskyAgent, + account: SessionAccount, +) { + switchToBskyAppLabeler() + if (IS_TEST_USER(account.handle)) { + await trySwitchToTestAppLabeler(agent) + } + + const labelerDids = await readLabelers(account.did).catch(_ => {}) + if (labelerDids) { + agent.configureLabelersHeader( + labelerDids.filter(did => did !== BSKY_LABELER_DID), + ) + } else { + // If there are no headers in the storage, we'll not send them on the initial requests. + // If we wanted to fix this, we could block on the preferences query here. + } +} + +function switchToBskyAppLabeler() { + BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) +} + +async function trySwitchToTestAppLabeler(agent: BskyAgent) { + const did = ( + await agent + .resolveHandle({handle: 'mod-authority.test'}) + .catch(_ => undefined) + )?.data.did + if (did) { + console.warn('USING TEST ENV MODERATION') + BskyAgent.configure({appLabelers: [did]}) + } +} + +export function isSessionExpired(account: SessionAccount) { + try { + if (account.accessJwt) { + const decoded = jwtDecode(account.accessJwt) + if (decoded.exp) { + const didExpire = Date.now() >= decoded.exp * 1000 + return didExpire + } + } + } catch (e) { + logger.error(`session: could not decode jwt`) + } + return true +} + +export async function createAgentAndLogin({ + service, + identifier, + password, + authFactorToken, +}: { + service: string + identifier: string + password: string + authFactorToken?: string +}) { + const agent = new BskyAgent({service}) + await agent.login({identifier, password, authFactorToken}) + + const account = agentToSessionAccount(agent) + if (!agent.session || !account) { + throw new Error(`session: login failed to establish a session`) + } + + const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates') + await configureModerationForAccount(agent, account) + + return { + agent, + account, + fetchingGates, + } +} + +export async function createAgentAndCreateAccount({ + service, + email, + password, + handle, + inviteCode, + verificationPhone, + verificationCode, +}: Parameters<SessionApiContext['createAccount']>[0]) { + const agent = new BskyAgent({service}) + await agent.createAccount({ + email, + password, + handle, + inviteCode, + verificationPhone, + verificationCode, + }) + + const account = agentToSessionAccount(agent)! + if (!agent.session || !account) { + throw new Error(`session: createAccount failed to establish a session`) + } + + const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates') + + if (!account.deactivated) { + /*dont await*/ agent.upsertProfile(_existing => { + return { + displayName: '', + + // HACKFIX + // creating a bunch of identical profile objects is breaking the relay + // tossing this unspecced field onto it to reduce the size of the problem + // -prf + createdAt: new Date().toISOString(), + } + }) + } + + await configureModerationForAccount(agent, account) + + return { + agent, + account, + fetchingGates, + } +} diff --git a/src/state/session/util/readLastActiveAccount.ts b/src/state/session/util/readLastActiveAccount.ts deleted file mode 100644 index e0768b8a8..000000000 --- a/src/state/session/util/readLastActiveAccount.ts +++ /dev/null @@ -1,6 +0,0 @@ -import * as persisted from '#/state/persisted' - -export function readLastActiveAccount() { - const {currentAccount, accounts} = persisted.get('session') - return accounts.find(a => a.did === currentAccount?.did) -} |