mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 04:32:34 +09:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a65832a65 | ||
|
|
fd6ae4abd7 | ||
|
|
24b84c419c | ||
|
|
746a75187a | ||
|
|
10ba2ca896 | ||
|
|
977b6767cd | ||
|
|
f00c6ee47a | ||
|
|
82859dc2f6 | ||
|
|
328dc06ab0 | ||
|
|
ea6e4a75db | ||
|
|
69ca462930 | ||
|
|
f38619d974 | ||
|
|
6c0ce9369b | ||
|
|
00c1b19809 | ||
|
|
2fd5daf104 | ||
|
|
fbf0f6adfe | ||
|
|
d91b5bec66 | ||
|
|
0f24cfb6ef |
@@ -632,6 +632,8 @@ describe('Deferred phone verification gate', () => {
|
||||
await configurePhoneGate({deferred_phone_gate_window_hours: 0.0001});
|
||||
const outsideWindow = await createGuildWithInvite(harness);
|
||||
await addFillerMember(outsideWindow.inviteCode);
|
||||
const beforeJoin = await readFlags(subject.userId);
|
||||
expect(beforeJoin & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
await createBuilder(harness, subject.token).post(`/invites/${outsideWindow.inviteCode}`).expect(200).execute();
|
||||
|
||||
const flags = await readFlags(subject.userId);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
type ChannelID,
|
||||
createChannelID,
|
||||
createMessageID,
|
||||
createUserID,
|
||||
type MessageID,
|
||||
type UserID,
|
||||
} from '../../../BrandedTypes';
|
||||
import type {ChannelRow} from '../../../database/types/ChannelTypes';
|
||||
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '../../../infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
import {Channel} from '../../../models/Channel';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {MessageProcessingService} from './MessageProcessingService';
|
||||
|
||||
const CHANNEL_ID = createChannelID(1532860318772891648n);
|
||||
const AUTHOR_ID = createUserID(1471426754353995881n);
|
||||
const RECIPIENT_ID = createUserID(1485344055661987728n);
|
||||
const MESSAGE_ID = createMessageID(1546325276953149440n);
|
||||
|
||||
function dmChannelRow(lastMessageId: MessageID | null): ChannelRow {
|
||||
return {
|
||||
channel_id: CHANNEL_ID,
|
||||
guild_id: null,
|
||||
type: ChannelTypes.DM,
|
||||
name: null,
|
||||
topic: null,
|
||||
icon_hash: null,
|
||||
url: null,
|
||||
parent_id: null,
|
||||
position: null,
|
||||
owner_id: null,
|
||||
recipient_ids: new Set<UserID>([AUTHOR_ID, RECIPIENT_ID]),
|
||||
nsfw: null,
|
||||
content_warning_level: null,
|
||||
content_warning_text: null,
|
||||
rate_limit_per_user: null,
|
||||
bitrate: null,
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
last_message_id: lastMessageId,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
nicks: null,
|
||||
soft_deleted: false,
|
||||
indexed_at: null,
|
||||
version: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildService(): {service: MessageProcessingService; opened: Array<Channel>} {
|
||||
const opened: Array<Channel> = [];
|
||||
const userRepository = {
|
||||
isDmChannelOpen: async (userId: UserID, _channelId: ChannelID) => userId === AUTHOR_ID,
|
||||
openPrivateChannelForUser: async (_userId: UserID, channel: Channel) => {
|
||||
opened.push(channel);
|
||||
},
|
||||
} as unknown as IUserRepository;
|
||||
const userCacheService = {
|
||||
getUserPartialResponses: async (userIds: Array<UserID>) =>
|
||||
new Map(userIds.map((userId) => [userId, {id: userId.toString()}])),
|
||||
} as unknown as UserCacheService;
|
||||
const gatewayService = {
|
||||
dispatchPresence: async () => {},
|
||||
} as unknown as IGatewayService;
|
||||
const service = new MessageProcessingService(
|
||||
undefined as never,
|
||||
userRepository,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
);
|
||||
return {service, opened};
|
||||
}
|
||||
|
||||
describe('MessageProcessingService.updateDMRecipients', () => {
|
||||
it('snapshots the new message id when the in-request channel is stale', async () => {
|
||||
const {service, opened} = buildService();
|
||||
await service.updateDMRecipients({
|
||||
channel: new Channel(dmChannelRow(null)),
|
||||
channelId: CHANNEL_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
requestCache: {} as RequestCache,
|
||||
});
|
||||
expect(opened).toHaveLength(1);
|
||||
expect(opened[0].lastMessageId).toBe(MESSAGE_ID);
|
||||
});
|
||||
|
||||
it('keeps a newer last message id already present on the channel', async () => {
|
||||
const {service, opened} = buildService();
|
||||
const newer = createMessageID(MESSAGE_ID + 10n);
|
||||
await service.updateDMRecipients({
|
||||
channel: new Channel(dmChannelRow(newer)),
|
||||
channelId: CHANNEL_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
requestCache: {} as RequestCache,
|
||||
});
|
||||
expect(opened).toHaveLength(1);
|
||||
expect(opened[0].lastMessageId).toBe(newer);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import type {GatewayChannelMention, IGatewayService} from '../../../infrastructu
|
||||
import type {UserCacheService} from '../../../infrastructure/UserCacheService';
|
||||
import {Logger} from '../../../Logger';
|
||||
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '../../../models/Channel';
|
||||
import {Channel} from '../../../models/Channel';
|
||||
import type {Message} from '../../../models/Message';
|
||||
import type {User} from '../../../models/User';
|
||||
import type {ReadStateService} from '../../../read_state/ReadStateService';
|
||||
@@ -33,6 +33,13 @@ interface MentionProcessingResult {
|
||||
mentionChannels: Array<GatewayChannelMention>;
|
||||
}
|
||||
|
||||
function channelWithLastMessageId(channel: Channel, messageId: MessageID): Channel {
|
||||
if (channel.lastMessageId != null && channel.lastMessageId >= messageId) {
|
||||
return channel;
|
||||
}
|
||||
return new Channel({...channel.toRow(), last_message_id: messageId});
|
||||
}
|
||||
|
||||
export class MessageProcessingService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
@@ -64,10 +71,12 @@ export class MessageProcessingService {
|
||||
async updateDMRecipients({
|
||||
channel,
|
||||
channelId,
|
||||
messageId,
|
||||
requestCache,
|
||||
}: {
|
||||
channel: Channel;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
if (channel.guildId || channel.type !== ChannelTypes.DM) return;
|
||||
@@ -76,11 +85,12 @@ export class MessageProcessingService {
|
||||
const openStates = await this.batchCheckDmChannelOpen(recipientIds, channelId);
|
||||
const closedRecipients = openStates.filter((state) => !state.isOpen);
|
||||
if (closedRecipients.length === 0) return;
|
||||
const snapshotChannel = channelWithLastMessageId(channel, messageId);
|
||||
await Promise.all(
|
||||
closedRecipients.map((state) =>
|
||||
this.openDmAndDispatch({
|
||||
recipientId: state.recipientId,
|
||||
channel,
|
||||
channel: snapshotChannel,
|
||||
requestCache,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -980,7 +980,7 @@ export class MessageSendService {
|
||||
await this.settlePostCreateWork(messageId, [
|
||||
{
|
||||
step: 'update_dm_recipients',
|
||||
promise: this.deps.processingService.updateDMRecipients({channel, channelId, requestCache}),
|
||||
promise: this.deps.processingService.updateDMRecipients({channel, channelId, messageId, requestCache}),
|
||||
},
|
||||
{
|
||||
step: 'process_message_after_creation',
|
||||
|
||||
@@ -197,7 +197,7 @@ export class UserSettings {
|
||||
return {
|
||||
user_id: userId,
|
||||
locale,
|
||||
theme: theme ?? ThemeTypes.SYSTEM,
|
||||
theme: theme ?? ThemeTypes.DARK,
|
||||
status: 'online',
|
||||
status_resets_at: null,
|
||||
status_resets_to: null,
|
||||
|
||||
@@ -22705,7 +22705,8 @@
|
||||
"type": "string",
|
||||
"description": "Maximum number of saved messages to return (1-100, default 25)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{"name": "before", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/SnowflakeType"}}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
let cachedEnabled = false;
|
||||
// Null until a policy has actually been read. A deferral is only ever granted
|
||||
// while the gate is on, so reading "not known yet" as "off" revokes it from every
|
||||
// account holding one and answers their next request with the phone requirement
|
||||
// the deferral exists to hold back.
|
||||
let cachedEnabled: boolean | null = null;
|
||||
|
||||
export function resolveDeferredPhoneGateEnabled(policy: {
|
||||
deferred_phone_gate_enabled: boolean;
|
||||
@@ -9,7 +13,7 @@ export function resolveDeferredPhoneGateEnabled(policy: {
|
||||
return policy.deferred_phone_gate_enabled && !policy.single_community_enabled;
|
||||
}
|
||||
|
||||
export function getCachedDeferredPhoneGateEnabled(): boolean {
|
||||
export function getCachedDeferredPhoneGateEnabled(): boolean | null {
|
||||
return cachedEnabled;
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ function suppressDeferredPhoneFlags(rawFlags: number): number {
|
||||
if ((rawFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0) {
|
||||
return rawFlags;
|
||||
}
|
||||
if (!getCachedDeferredPhoneGateEnabled()) {
|
||||
if (getCachedDeferredPhoneGateEnabled() === false) {
|
||||
return rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN;
|
||||
}
|
||||
return rawFlags & ~DEFERRABLE_PHONE_FLAGS;
|
||||
|
||||
@@ -123,9 +123,11 @@ export function UserContentController(app: HonoApp) {
|
||||
'Retrieves all messages saved by the current user. Messages are saved privately for easy reference. Returns paginated list of saved messages with metadata.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {limit, before} = ctx.req.valid('query');
|
||||
const response = await ctx.get('userContentRequestService').listSavedMessages({
|
||||
userId: ctx.get('user').id,
|
||||
limit: ctx.req.valid('query').limit,
|
||||
limit,
|
||||
before: before ? createMessageID(before) : undefined,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(response, 200);
|
||||
|
||||
@@ -46,6 +46,7 @@ interface UserMentionsReadParams {
|
||||
interface SavedMessagesParams {
|
||||
userId: UserID;
|
||||
limit: number;
|
||||
before?: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
@@ -108,7 +109,11 @@ export class UserContentRequestService {
|
||||
}
|
||||
|
||||
async listSavedMessages(params: SavedMessagesParams): Promise<SavedMessageEntryListResponse> {
|
||||
const entries = await this.userContentService.getSavedMessages({userId: params.userId, limit: params.limit});
|
||||
const entries = await this.userContentService.getSavedMessages({
|
||||
userId: params.userId,
|
||||
limit: params.limit,
|
||||
before: params.before,
|
||||
});
|
||||
const messages = entries.map((entry) => entry.message).filter((message): message is Message => message != null);
|
||||
const responses = await this.userContentService.buildMessageResponsesForUser(params.userId, messages);
|
||||
const responseByMessageId = new Map(responses.map((response) => [response.id, response] as const));
|
||||
|
||||
@@ -100,6 +100,7 @@ function createUserContentService({
|
||||
}) {
|
||||
const batchCalls: Array<ChannelBatchCall> = [];
|
||||
const deletedSavedMessageIds: Array<string> = [];
|
||||
const savedMessageListCalls: Array<{limit?: number; before?: MessageID}> = [];
|
||||
const readableByChannel = new Map<string, Map<string, Message>>();
|
||||
for (const entry of readable) {
|
||||
const key = entry.channelId.toString();
|
||||
@@ -109,7 +110,10 @@ function createUserContentService({
|
||||
}
|
||||
const userRepository = {
|
||||
listRecentMentions: async () => entries,
|
||||
listSavedMessages: async () => entries,
|
||||
listSavedMessages: async (_userId: UserID, limit?: number, before?: MessageID) => {
|
||||
savedMessageListCalls.push({limit, before});
|
||||
return entries;
|
||||
},
|
||||
deleteSavedMessage: async (_userId: UserID, messageId: MessageID) => {
|
||||
deletedSavedMessageIds.push(messageId.toString());
|
||||
},
|
||||
@@ -146,7 +150,7 @@ function createUserContentService({
|
||||
{} as unknown as KVBulkMessageDeletionQueueService,
|
||||
{} as unknown as LimitConfigService,
|
||||
);
|
||||
return {service, batchCalls, deletedSavedMessageIds};
|
||||
return {service, batchCalls, deletedSavedMessageIds, savedMessageListCalls};
|
||||
}
|
||||
|
||||
const CHANNEL_A = createChannelID(100n);
|
||||
@@ -238,6 +242,15 @@ describe('getRecentMentions', () => {
|
||||
});
|
||||
|
||||
describe('getSavedMessages', () => {
|
||||
it('passes the page cursor to the repository', async () => {
|
||||
const entries = [{channelId: CHANNEL_A, messageId: createMessageID(11n)}];
|
||||
const {service, savedMessageListCalls} = createUserContentService({entries, readable: entries});
|
||||
|
||||
await service.getSavedMessages({userId: VIEWER_ID, limit: 50, before: createMessageID(20n)});
|
||||
|
||||
expect(savedMessageListCalls).toEqual([{limit: 50, before: createMessageID(20n)}]);
|
||||
});
|
||||
|
||||
it('marks every entry of an unreachable channel as missing permissions without deleting it', async () => {
|
||||
const entries = [
|
||||
{channelId: CHANNEL_A, messageId: createMessageID(11n)},
|
||||
|
||||
@@ -229,8 +229,16 @@ export class UserContentService {
|
||||
);
|
||||
}
|
||||
|
||||
async getSavedMessages({userId, limit}: {userId: UserID; limit: number}): Promise<Array<SavedMessageEntry>> {
|
||||
const savedMessages = await this.userRepository.listSavedMessages(userId, limit);
|
||||
async getSavedMessages({
|
||||
userId,
|
||||
limit,
|
||||
before,
|
||||
}: {
|
||||
userId: UserID;
|
||||
limit: number;
|
||||
before?: MessageID;
|
||||
}): Promise<Array<SavedMessageEntry>> {
|
||||
const savedMessages = await this.userRepository.listSavedMessages(userId, limit, before);
|
||||
const messagesByChannel = await this.readMessagesByChannel(userId, savedMessages);
|
||||
const results: Array<SavedMessageEntry> = [];
|
||||
const staleMessageIds: Array<MessageID> = [];
|
||||
|
||||
@@ -338,9 +338,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
const unreadCount = ReadStates.getUnreadCount(channelId);
|
||||
const hasUnread = ReadStates.hasUnread(channelId);
|
||||
const mentionCount = ReadStates.getMentionCount(channelId);
|
||||
const isMuted =
|
||||
UserGuildSettings.isParentCategoryMuted(guild.id, channelId) ||
|
||||
UserGuildSettings.isChannelDirectlyMuted(guild.id, channelId);
|
||||
const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, channelId);
|
||||
const channel = Channels.getChannel(channelId);
|
||||
const unreadBadgesLevel = channel
|
||||
? UserGuildSettings.resolvedUnreadBadgesLevel({
|
||||
@@ -364,18 +362,21 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
for (const group of channelGroups) {
|
||||
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
|
||||
const isNullSpace = !group.category;
|
||||
const isCategoryMuted = group.category
|
||||
? UserGuildSettings.isChannelDirectlyMuted(guild.id, group.category.id)
|
||||
: false;
|
||||
const isCategoryMuted =
|
||||
hideMutedChannels && group.category
|
||||
? UserGuildSettings.isChannelDirectlyMuted(guild.id, group.category.id)
|
||||
: false;
|
||||
let filteredTextChannels: Array<Channel>;
|
||||
let filteredVoiceChannels: Array<Channel>;
|
||||
if (hideMutedChannels) {
|
||||
filteredTextChannels = [];
|
||||
for (const ch of group.textChannels) {
|
||||
const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
|
||||
const isChannelMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
|
||||
const isMuted = isCategoryMuted || isChannelMuted;
|
||||
if (
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
isCategoryMuted,
|
||||
isChannelMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
|
||||
@@ -386,10 +387,12 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
}
|
||||
filteredVoiceChannels = [];
|
||||
for (const ch of group.voiceChannels) {
|
||||
const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
|
||||
const isChannelMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
|
||||
const isMuted = isCategoryMuted || isChannelMuted;
|
||||
if (
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
isCategoryMuted,
|
||||
isChannelMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
isConnected: ch.id === connectedChannelId,
|
||||
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
|
||||
@@ -411,8 +414,8 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
for (const ch of filteredTextChannels) {
|
||||
if (
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted,
|
||||
isSelected: ch.id === showTextSelected,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
@@ -442,8 +445,8 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
isConnected: ch.id === connectedChannelId,
|
||||
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
|
||||
@@ -11,7 +11,8 @@ describe('shouldShowChannelWhenHidingMutedChannels', () => {
|
||||
it('keeps muted channels with visible unread state so mentions are findable', () => {
|
||||
expect(
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted: true,
|
||||
isCategoryMuted: false,
|
||||
isChannelMuted: true,
|
||||
isSelected: false,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: true,
|
||||
@@ -22,7 +23,8 @@ describe('shouldShowChannelWhenHidingMutedChannels', () => {
|
||||
it('hides muted channels without visible unread state', () => {
|
||||
expect(
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted: true,
|
||||
isCategoryMuted: false,
|
||||
isChannelMuted: true,
|
||||
isSelected: false,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: false,
|
||||
@@ -30,10 +32,35 @@ describe('shouldShowChannelWhenHidingMutedChannels', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('inherits the mute from a muted category', () => {
|
||||
expect(
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isCategoryMuted: true,
|
||||
isChannelMuted: false,
|
||||
isSelected: false,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps channels of a muted category that have visible unread state', () => {
|
||||
expect(
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isCategoryMuted: true,
|
||||
isChannelMuted: false,
|
||||
isSelected: false,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps selected and connected muted channels visible', () => {
|
||||
expect(
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted: true,
|
||||
isCategoryMuted: true,
|
||||
isChannelMuted: true,
|
||||
isSelected: true,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: false,
|
||||
@@ -41,7 +68,8 @@ describe('shouldShowChannelWhenHidingMutedChannels', () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted: true,
|
||||
isCategoryMuted: true,
|
||||
isChannelMuted: false,
|
||||
isSelected: false,
|
||||
isConnected: true,
|
||||
hasVisibleUnread: false,
|
||||
@@ -52,7 +80,8 @@ describe('shouldShowChannelWhenHidingMutedChannels', () => {
|
||||
it('keeps unmuted channels visible', () => {
|
||||
expect(
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted: false,
|
||||
isCategoryMuted: false,
|
||||
isChannelMuted: false,
|
||||
isSelected: false,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: false,
|
||||
@@ -94,27 +123,37 @@ describe('shouldShowChannelInCollapsedCategory', () => {
|
||||
it('keeps visible unread channels reachable in collapsed categories', () => {
|
||||
expect(
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted: false,
|
||||
isSelected: false,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not reveal unread channels from muted collapsed categories unless selected', () => {
|
||||
it('keeps selected and connected channels visible without unread state', () => {
|
||||
expect(
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted: true,
|
||||
isSelected: false,
|
||||
hasVisibleUnread: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted: true,
|
||||
isSelected: true,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isSelected: false,
|
||||
isConnected: true,
|
||||
hasVisibleUnread: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides read channels in collapsed categories', () => {
|
||||
expect(
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isSelected: false,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface HiddenMutedChannelVisibilityInput {
|
||||
isMuted: boolean;
|
||||
isCategoryMuted: boolean;
|
||||
isChannelMuted: boolean;
|
||||
isSelected: boolean;
|
||||
isConnected: boolean;
|
||||
hasVisibleUnread: boolean;
|
||||
}
|
||||
|
||||
export function shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
isCategoryMuted,
|
||||
isChannelMuted,
|
||||
isSelected,
|
||||
isConnected,
|
||||
hasVisibleUnread,
|
||||
}: HiddenMutedChannelVisibilityInput): boolean {
|
||||
return isSelected || isConnected || !isMuted || hasVisibleUnread;
|
||||
return isSelected || isConnected || !(isCategoryMuted || isChannelMuted) || hasVisibleUnread;
|
||||
}
|
||||
|
||||
export interface HiddenMutedCategoryVisibilityInput {
|
||||
@@ -29,15 +31,15 @@ export function shouldShowCategoryWhenHidingMutedChannels({
|
||||
}
|
||||
|
||||
export interface CollapsedCategoryChannelVisibilityInput {
|
||||
isCategoryMuted: boolean;
|
||||
isSelected: boolean;
|
||||
isConnected: boolean;
|
||||
hasVisibleUnread: boolean;
|
||||
}
|
||||
|
||||
export function shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted,
|
||||
isSelected,
|
||||
isConnected,
|
||||
hasVisibleUnread,
|
||||
}: CollapsedCategoryChannelVisibilityInput): boolean {
|
||||
return isSelected || (!isCategoryMuted && hasVisibleUnread);
|
||||
return isSelected || isConnected || hasVisibleUnread;
|
||||
}
|
||||
|
||||
@@ -430,7 +430,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [stepNavigationLocked, setStepNavigationLocked] = useState(false);
|
||||
const [wizardSnapshot, setWizardSnapshot] = useState(createSetupWizardSnapshot);
|
||||
const [setupTheme, setSetupTheme] = useState<ThemeType>(ThemeTypes.SYSTEM);
|
||||
const [setupTheme, setSetupTheme] = useState<ThemeType>(ThemeTypes.DARK);
|
||||
const [forceUnauthenticatedSetup, setForceUnauthenticatedSetup] = useState(false);
|
||||
const [integrationDraft, setIntegrationDraft] = useState<ServiceIntegrationDraft>(() => ({
|
||||
...DEFAULT_INTEGRATION_DRAFT,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {useAntiShiftFloating} from '@app/features/app/hooks/useAntiShiftFloating';
|
||||
import {act, createElement, useLayoutEffect, useState} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
let target: HTMLDivElement;
|
||||
|
||||
function PortalLikeFloating({onReady}: {onReady: (isReady: boolean) => void}) {
|
||||
const {setFloating, state} = useAntiShiftFloating(target, true, {placement: 'top'});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
onReady(state.isReady);
|
||||
return mounted ? createElement('div', {ref: setFloating, 'data-testid': 'floating'}) : null;
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement('div');
|
||||
target = document.createElement('div');
|
||||
document.body.append(host, target);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe('useAntiShiftFloating', () => {
|
||||
it('positions a floating element that mounts after the first commit', async () => {
|
||||
const readyStates: Array<boolean> = [];
|
||||
act(() => {
|
||||
root.render(createElement(PortalLikeFloating, {onReady: (isReady) => readyStates.push(isReady)}));
|
||||
});
|
||||
await settle();
|
||||
expect(readyStates.at(-1)).toBe(true);
|
||||
expect(host.querySelector<HTMLElement>('[data-testid="floating"]')?.style.visibility).toBe('visible');
|
||||
});
|
||||
});
|
||||
@@ -279,6 +279,11 @@ export function useAntiShiftFloating(
|
||||
constrainHeight = false,
|
||||
} = options;
|
||||
const floatingRef = useRef<HTMLElement>(null);
|
||||
const [floatingElement, setFloatingElement] = useState<HTMLElement | null>(null);
|
||||
const setFloating = useCallback((element: HTMLElement | null) => {
|
||||
floatingRef.current = element;
|
||||
setFloatingElement(element);
|
||||
}, []);
|
||||
const [state, setState] = useState<FloatingState>(() => {
|
||||
const {x, y} = target ? getInitialGuess(target, placement, offsetMainAxis, offsetCrossAxis) : {x: -9999, y: -9999};
|
||||
return {x, y, isReady: false, offsetX: 0, offsetY: 0, placement};
|
||||
@@ -481,7 +486,7 @@ export function useAntiShiftFloating(
|
||||
};
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled || target == null || floatingRef.current == null) {
|
||||
if (!enabled || target == null || floatingElement == null) {
|
||||
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
|
||||
return;
|
||||
}
|
||||
@@ -489,7 +494,7 @@ export function useAntiShiftFloating(
|
||||
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
|
||||
return;
|
||||
}
|
||||
const floating = floatingRef.current;
|
||||
const floating = floatingElement;
|
||||
updatePosition();
|
||||
if (shouldAutoUpdate) {
|
||||
const cleanupCallbacks = [
|
||||
@@ -502,7 +507,7 @@ export function useAntiShiftFloating(
|
||||
}
|
||||
};
|
||||
} else if (shouldObserveFloatingResize) {
|
||||
cleanupRef.current = observeFloatingResize(floatingRef.current, updatePosition);
|
||||
cleanupRef.current = observeFloatingResize(floating, updatePosition);
|
||||
}
|
||||
return () => {
|
||||
positionRevisionRef.current += 1;
|
||||
@@ -519,9 +524,18 @@ export function useAntiShiftFloating(
|
||||
}
|
||||
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
|
||||
};
|
||||
}, [enabled, target, shouldAutoUpdate, shouldObserveFloatingResize, updatePosition, updatePositionNow]);
|
||||
}, [
|
||||
enabled,
|
||||
target,
|
||||
floatingElement,
|
||||
shouldAutoUpdate,
|
||||
shouldObserveFloatingResize,
|
||||
updatePosition,
|
||||
updatePositionNow,
|
||||
]);
|
||||
return {
|
||||
ref: floatingRef,
|
||||
setFloating,
|
||||
state,
|
||||
style: {
|
||||
position: 'fixed' as const,
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
resolveChannelMessagesWindowStatus,
|
||||
selectChannelMessagesFillerVisible,
|
||||
selectChannelMessagesSpacerHeight,
|
||||
selectChannelMessagesTailGapId,
|
||||
selectChannelMessagesTailProbeId,
|
||||
selectChannelMessagesWindowBar,
|
||||
} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
|
||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||
@@ -66,7 +68,11 @@ import {clsx} from 'clsx';
|
||||
import {runInAction} from 'mobx';
|
||||
import {observer, useLocalObservable} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useMemo, useRef} from 'react';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const TAIL_PROBE_MAX_ATTEMPTS = 2;
|
||||
const TAIL_PROBE_MIN_INTERVAL_MS = 10_000;
|
||||
const tailProbeAttemptedAt = new Map<string, number>();
|
||||
|
||||
const MESSAGE_LIST_FOR_DESCRIPTOR = msg({
|
||||
message: 'Message list for {channelName}',
|
||||
@@ -167,6 +173,9 @@ export const Messages = observer(function Messages({
|
||||
const scrollerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastStateSnapshotRef = useRef<MessagesStateSnapshot | null>(null);
|
||||
const recoveryFetchChannelIdRef = useRef<string | null>(null);
|
||||
const tailProbeKeyRef = useRef<string | null>(null);
|
||||
const tailProbeAttemptsRef = useRef<{key: string; attempts: number} | null>(null);
|
||||
const [settledTailProbeKey, setSettledTailProbeKey] = useState<string | null>(null);
|
||||
interface MessageState extends MessagesStateSnapshot {
|
||||
highlightedMessageId: string | null;
|
||||
isAtBottom: boolean;
|
||||
@@ -202,6 +211,17 @@ export const Messages = observer(function Messages({
|
||||
});
|
||||
const windowBar = selectChannelMessagesWindowBar(windowStatus);
|
||||
const windowNeedsPage = windowStatus.needsPage;
|
||||
const tailInput = {
|
||||
status: windowStatus,
|
||||
loading: safeMessages.loadingMore || safeMessages.probeLoading,
|
||||
newestLoadedMessageId: safeMessages.last()?.id ?? null,
|
||||
knownLatestMessageId: state.lastReadStateMessageId,
|
||||
};
|
||||
const tailWatermarkMessageId = state.lastReadStateMessageId;
|
||||
const tailGapMessageId = selectChannelMessagesTailGapId(tailInput);
|
||||
const tailProbeMessageId = selectChannelMessagesTailProbeId(tailInput);
|
||||
const tailGapKey = tailGapMessageId == null ? null : `${tailGapMessageId}:${tailWatermarkMessageId}`;
|
||||
const tailProbeKey = tailProbeMessageId == null ? null : `${tailProbeMessageId}:${tailWatermarkMessageId}`;
|
||||
const canAutoAck = shouldAutoAck({
|
||||
channelActive: allowAutoAck,
|
||||
windowFocused: isWindowFocused,
|
||||
@@ -454,6 +474,47 @@ export const Messages = observer(function Messages({
|
||||
}
|
||||
});
|
||||
}, [channel.id, isGatewayConnected, selectedChannelId, windowNeedsPage, state.messageVersion]);
|
||||
useEffect(() => {
|
||||
if (!isGatewayConnected) {
|
||||
tailProbeKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (tailProbeMessageId == null || tailProbeKey == null || tailWatermarkMessageId == null) {
|
||||
return;
|
||||
}
|
||||
if (selectedChannelId !== channel.id || tailProbeKeyRef.current === tailProbeKey) {
|
||||
return;
|
||||
}
|
||||
const lastAttemptAt = tailProbeAttemptedAt.get(tailProbeKey);
|
||||
if (lastAttemptAt != null && Date.now() - lastAttemptAt < TAIL_PROBE_MIN_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
tailProbeKeyRef.current = tailProbeKey;
|
||||
tailProbeAttemptedAt.set(tailProbeKey, Date.now());
|
||||
void MessageCommands.fetchMessages(channel.id, null, tailProbeMessageId, MAX_MESSAGES_PER_CHANNEL, undefined, {
|
||||
tailProbe: {
|
||||
watermarkMessageId: tailWatermarkMessageId,
|
||||
onSettled: (settlement) => {
|
||||
if (settlement === 'applied') {
|
||||
setSettledTailProbeKey(tailProbeKey);
|
||||
return;
|
||||
}
|
||||
if (settlement === 'failed') {
|
||||
const attempts =
|
||||
tailProbeAttemptsRef.current?.key === tailProbeKey ? tailProbeAttemptsRef.current.attempts : 0;
|
||||
if (attempts >= TAIL_PROBE_MAX_ATTEMPTS) {
|
||||
setSettledTailProbeKey(tailProbeKey);
|
||||
return;
|
||||
}
|
||||
tailProbeAttemptsRef.current = {key: tailProbeKey, attempts: attempts + 1};
|
||||
}
|
||||
if (tailProbeKeyRef.current === tailProbeKey) {
|
||||
tailProbeKeyRef.current = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [channel.id, isGatewayConnected, selectedChannelId, tailProbeKey, tailProbeMessageId, tailWatermarkMessageId]);
|
||||
useMessageListKeyboardNavigation({
|
||||
containerRef: scrollManager.ref,
|
||||
channelId: channel.id,
|
||||
@@ -488,10 +549,11 @@ export const Messages = observer(function Messages({
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!canAutoAck || !state.isAtBottom || !state.messages?.ready) return;
|
||||
if (tailGapKey != null && settledTailProbeKey !== tailGapKey) return;
|
||||
if (ReadStates.hasUnread(channel.id)) {
|
||||
ReadStateCommands.ackWithStickyUnread(channel.id);
|
||||
}
|
||||
}, [canAutoAck, state.isAtBottom, state.messages?.ready, channel.id]);
|
||||
}, [canAutoAck, state.isAtBottom, state.messages?.ready, tailGapKey, settledTailProbeKey, channel.id]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const readState = ReadStates.getIfExists(channel.id);
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
|
||||
.loadingState {
|
||||
display: flex;
|
||||
height: 5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.messagePreviewCard {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils
|
||||
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
|
||||
import {BottomSheet} from '@app/features/ui/bottom_sheet/BottomSheet';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import type {MenuGroupType} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
|
||||
import {MenuBottomSheet} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
|
||||
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
|
||||
@@ -44,6 +45,8 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
|
||||
const {i18n} = useLingui();
|
||||
const {savedMessages, missingSavedMessages, fetched} = SavedMessages;
|
||||
const hasBookmarks = savedMessages.length > 0 || missingSavedMessages.length > 0;
|
||||
const hasMore = SavedMessages.getHasMore();
|
||||
const isLoadingMore = SavedMessages.getIsLoadingMore();
|
||||
const scrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
const resolveBookmarksScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
|
||||
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
|
||||
@@ -61,6 +64,13 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
|
||||
useMessageListKeyboardNavigation({
|
||||
containerRef: scrollerRef,
|
||||
});
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
const scrollPercentage = (target.scrollTop + target.offsetHeight) / target.scrollHeight;
|
||||
if (scrollPercentage > 0.8 && hasMore && !isLoadingMore) {
|
||||
SavedMessageCommands.loadMore();
|
||||
}
|
||||
};
|
||||
const handleLongPress = (message: Message) => {
|
||||
setSelectedMessage(message);
|
||||
setMenuOpen(true);
|
||||
@@ -127,6 +137,7 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
|
||||
<NearViewportSurfaceContext.Provider value={resolveBookmarksScrollSurface}>
|
||||
<Scroller
|
||||
className={styles.messageList}
|
||||
onScroll={handleScroll}
|
||||
key="bookmarks-bottom-sheet-scroller"
|
||||
ref={scrollerRef}
|
||||
onCopy={onCopySelectedMessages}
|
||||
@@ -157,6 +168,11 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isLoadingMore && (
|
||||
<div className={styles.loadingState} data-flx="channel.bookmarks-bottom-sheet.loading-state">
|
||||
<Spinner data-flx="channel.bookmarks-bottom-sheet.spinner" />
|
||||
</div>
|
||||
)}
|
||||
</Scroller>
|
||||
</NearViewportSurfaceContext.Provider>
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,6 @@ import {handleFavoriteMemeDelete} from '@app/features/expressions/events/Favorit
|
||||
import {handleFavoriteMemeUpdate} from '@app/features/expressions/events/FavoriteMemeUpdate';
|
||||
import {handleWebhooksUpdate} from '@app/features/expressions/events/WebhooksUpdate';
|
||||
import {handleReady} from '@app/features/gateway/events/GatewayReady';
|
||||
import {handleResumed} from '@app/features/gateway/events/GatewayResumed';
|
||||
import type {GatewaySocket} from '@app/features/gateway/transport/GatewaySocket';
|
||||
import {handleChannelMemberCountsUpdate} from '@app/features/guild/events/ChannelMemberCountsUpdate';
|
||||
import {handleGuildBanAdd, handleGuildBanRemove} from '@app/features/guild/events/GuildBan';
|
||||
@@ -92,7 +91,6 @@ export type GatewayHandlerRegistry = Map<string, GatewayEventHandler>;
|
||||
export function createHandlerRegistry(): GatewayHandlerRegistry {
|
||||
const registry: GatewayHandlerRegistry = new Map();
|
||||
registry.set('READY', handleReady as GatewayEventHandler);
|
||||
registry.set('RESUMED', handleResumed as GatewayEventHandler);
|
||||
registry.set('AUTH_SESSION_CHANGE', handleAuthSessionChange as GatewayEventHandler);
|
||||
registry.set('USER_UPDATE', handleUserUpdate as GatewayEventHandler);
|
||||
registry.set('USER_SETTINGS_UPDATE', handleUserSettingsUpdate as GatewayEventHandler);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Messages from '@app/features/messaging/state/MessagingMessages';
|
||||
|
||||
export function handleResumed(): void {
|
||||
Messages.handleResumed();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import GeoIP from '@app/features/app/state/GeoIP';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import FavoriteMemes from '@app/features/expressions/state/FavoriteMemes';
|
||||
import {
|
||||
createHandlerRegistry,
|
||||
@@ -23,10 +24,12 @@ import {
|
||||
GatewayState,
|
||||
type GatewayVoiceStateUpdateParams,
|
||||
} from '@app/features/gateway/transport/GatewaySocket';
|
||||
import {selectGuildActivationTarget} from '@app/features/gateway/transport/GuildActivationTarget';
|
||||
import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import MemberSearch from '@app/features/member/state/MemberSearch';
|
||||
import Messages from '@app/features/messaging/state/MessagingMessages';
|
||||
import Navigation from '@app/features/navigation/state/Navigation';
|
||||
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
@@ -55,6 +58,7 @@ interface DesiredSession {
|
||||
class GatewayConnection {
|
||||
socket: GatewaySocket | null = null;
|
||||
isConnected: boolean = false;
|
||||
connectionEpoch: number = 0;
|
||||
isConnecting: boolean = false;
|
||||
isReady: boolean = false;
|
||||
sessionId: string | null = null;
|
||||
@@ -166,11 +170,11 @@ class GatewayConnection {
|
||||
deferUntilModulesLoaded(() => {
|
||||
reaction(
|
||||
() => ({
|
||||
guildId: SelectedGuild.selectedGuildId,
|
||||
guildId: this.activationGuildId,
|
||||
nonce: SelectedGuild.selectionNonce,
|
||||
}),
|
||||
({guildId}) => {
|
||||
if (!guildId || guildId === FAVORITES_GUILD_ID) {
|
||||
if (!guildId) {
|
||||
this.pendingGuildSyncId = null;
|
||||
return;
|
||||
}
|
||||
@@ -339,6 +343,9 @@ class GatewayConnection {
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (newState === GatewayState.Connected || previousState === GatewayState.Connected) {
|
||||
this.connectionEpoch += 1;
|
||||
}
|
||||
this.isConnected = newState === GatewayState.Connected;
|
||||
this.isConnecting = newState === GatewayState.Connecting || newState === GatewayState.Reconnecting;
|
||||
if (newState === GatewayState.Connected) {
|
||||
@@ -445,8 +452,17 @@ class GatewayConnection {
|
||||
}
|
||||
}
|
||||
|
||||
private get activationGuildId(): string | null {
|
||||
const channelId = Navigation.channelId;
|
||||
const channel = channelId ? Channels.getChannel(channelId) : undefined;
|
||||
return selectGuildActivationTarget({
|
||||
selectedGuildId: SelectedGuild.selectedGuildId,
|
||||
openChannelGuildId: channel?.guildId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
private flushPendingGuildSync(): void {
|
||||
const guildId = this.pendingGuildSyncId ?? SelectedGuild.selectedGuildId;
|
||||
const guildId = this.pendingGuildSyncId ?? this.activationGuildId;
|
||||
if (!guildId || guildId === FAVORITES_GUILD_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {selectGuildActivationTarget} from './GuildActivationTarget';
|
||||
|
||||
describe('selectGuildActivationTarget', () => {
|
||||
it('returns the selected guild on a guild route', () => {
|
||||
expect(selectGuildActivationTarget({selectedGuildId: '1', openChannelGuildId: '1'})).toBe('1');
|
||||
});
|
||||
|
||||
it('returns the selected guild when no channel is open', () => {
|
||||
expect(selectGuildActivationTarget({selectedGuildId: '1', openChannelGuildId: null})).toBe('1');
|
||||
});
|
||||
|
||||
it('returns the owning guild of a channel opened from the favorites route', () => {
|
||||
expect(selectGuildActivationTarget({selectedGuildId: null, openChannelGuildId: '1'})).toBe('1');
|
||||
});
|
||||
|
||||
it('ignores the favorites pseudo guild and falls through to the open channel', () => {
|
||||
expect(selectGuildActivationTarget({selectedGuildId: FAVORITES_GUILD_ID, openChannelGuildId: '1'})).toBe('1');
|
||||
});
|
||||
|
||||
it('returns null on the favorites route while no channel is open', () => {
|
||||
expect(selectGuildActivationTarget({selectedGuildId: FAVORITES_GUILD_ID, openChannelGuildId: null})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a private channel', () => {
|
||||
expect(selectGuildActivationTarget({selectedGuildId: null, openChannelGuildId: null})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
|
||||
|
||||
export function selectGuildActivationTarget(input: {
|
||||
selectedGuildId: string | null;
|
||||
openChannelGuildId: string | null;
|
||||
}): string | null {
|
||||
if (input.selectedGuildId && input.selectedGuildId !== FAVORITES_GUILD_ID) {
|
||||
return input.selectedGuildId;
|
||||
}
|
||||
return input.openChannelGuildId;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import ReadStates from '@app/features/read_state/state/ReadStates';
|
||||
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
|
||||
import Nagbar from '@app/features/ui/state/Nagbar';
|
||||
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
|
||||
import UserProfile from '@app/features/user/state/UserProfile';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
|
||||
|
||||
@@ -51,6 +52,7 @@ export function handleGuildCreate(data: GuildReadyData, _context: GatewayHandler
|
||||
GuildCount.handleGuildCreate(data);
|
||||
if (!isSync) {
|
||||
MemberSidebar.handleGuildCreate(data.id);
|
||||
UserProfile.handleGuildCreate();
|
||||
}
|
||||
if (!data.unavailable) {
|
||||
Channels.handleGuildCreate(data);
|
||||
|
||||
@@ -20,6 +20,7 @@ import MentionFeed from '@app/features/notification/state/MentionFeed';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import Presence from '@app/features/presence/state/Presence';
|
||||
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
|
||||
import UserProfile from '@app/features/user/state/UserProfile';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import Webhooks from '@app/features/webhook/state/Webhooks';
|
||||
import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
@@ -52,5 +53,6 @@ export function handleGuildDelete(data: GuildDeletePayload, _context: GatewayHan
|
||||
Messages.handleGuildUnavailable(data.id, data.unavailable ?? false);
|
||||
Messages.handleCleanup();
|
||||
MentionFeed.handleGuildDelete(data.id);
|
||||
UserProfile.handleGuildDelete(data.unavailable);
|
||||
QuickSwitcher.recomputeIfOpen();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import MemberSearch from '@app/features/member/state/MemberSearch';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import Presence from '@app/features/presence/state/Presence';
|
||||
import UserProfile from '@app/features/user/state/UserProfile';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {User} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
@@ -21,4 +22,5 @@ export function handleGuildMemberAdd(data: GuildMemberAddPayload, _context: Gate
|
||||
GuildVerification.handleGuildMemberUpdate(data.guild_id);
|
||||
Presence.handleGuildMemberAdd(data.guild_id, data.user.id);
|
||||
MemberSearch.handleMemberAdd(data.guild_id, data.user.id);
|
||||
UserProfile.handleGuildMemberAdd(data.user.id);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import MemberSearch from '@app/features/member/state/MemberSearch';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import Presence from '@app/features/presence/state/Presence';
|
||||
import UserProfile from '@app/features/user/state/UserProfile';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
@@ -19,4 +20,5 @@ export function handleGuildMemberRemove(data: GuildMemberRemovePayload, _context
|
||||
GuildMembers.handleMemberRemove(data.guild_id, data.user.id);
|
||||
Permission.handleGuildMemberUpdate(data.user.id);
|
||||
Presence.handleGuildMemberRemove(data.guild_id, data.user.id);
|
||||
UserProfile.handleGuildMemberRemove(data.user.id);
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ function SelectionToolbarSurface({
|
||||
}),
|
||||
[editor],
|
||||
);
|
||||
const {ref, state, style, updatePosition} = useAntiShiftFloating(virtualReference, true, {
|
||||
const {setFloating, state, style, updatePosition} = useAntiShiftFloating(virtualReference, true, {
|
||||
placement: 'top',
|
||||
offsetMainAxis: 8,
|
||||
shouldAutoUpdate: true,
|
||||
@@ -381,7 +381,7 @@ function SelectionToolbarSurface({
|
||||
>
|
||||
<flx-lexical-selection-formatting-toolbar
|
||||
ref={(element) => {
|
||||
(ref as React.MutableRefObject<HTMLElement | null>).current = element;
|
||||
setFloating(element);
|
||||
toolbarRef.current = element;
|
||||
}}
|
||||
className={flxElementClassName(styles.toolbar)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree';
|
||||
import {DELETE_MESSAGE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
type MessageFetchCacheHit,
|
||||
resolveMessageFetchExecutionDecision,
|
||||
resolveMessageFetchPreflightDecision,
|
||||
resolveMessageFetchWindowCached,
|
||||
} from '@app/features/messaging/commands/MessageFetchStateMachine';
|
||||
import {resolveMessagePageState} from '@app/features/messaging/commands/MessagePageStateMachine';
|
||||
import {MessageDeleteFailedModal} from '@app/features/messaging/components/alerts/MessageDeleteFailedModal';
|
||||
@@ -23,6 +25,7 @@ import {MessageEditFailedModal} from '@app/features/messaging/components/alerts/
|
||||
import {MessageEditTooQuickModal} from '@app/features/messaging/components/alerts/MessageEditTooQuickModal';
|
||||
import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
|
||||
import type {JumpOptions} from '@app/features/messaging/state/ChannelMessages';
|
||||
import {selectChannelMessagesTailProbeOutcome} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
|
||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||
import MessageEditMobile from '@app/features/messaging/state/MessageEditMobile';
|
||||
import MessageQueue from '@app/features/messaging/state/MessageQueue';
|
||||
@@ -115,6 +118,19 @@ export interface JumpToMessageOptions {
|
||||
|
||||
interface FetchMessagesOptions {
|
||||
throwOnError?: boolean;
|
||||
tailProbe?: TailProbeContext;
|
||||
}
|
||||
|
||||
export type TailProbeSettlement = 'applied' | 'retry' | 'failed';
|
||||
|
||||
export interface TailProbeContext {
|
||||
watermarkMessageId: string;
|
||||
onSettled: (settlement: TailProbeSettlement) => void;
|
||||
}
|
||||
|
||||
interface TailProbeEpoch {
|
||||
loadGeneration: number;
|
||||
jumpTicket: number;
|
||||
}
|
||||
|
||||
interface MessagePageState {
|
||||
@@ -142,11 +158,12 @@ function makeFetchKey(
|
||||
): string {
|
||||
const SEP = '\x1f';
|
||||
const throwOnError = options?.throwOnError ? '1' : '0';
|
||||
const tailProbe = options?.tailProbe ? `1.${options.tailProbe.watermarkMessageId}` : '0';
|
||||
if (!jump) {
|
||||
return `${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}`;
|
||||
return `${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}${SEP}${tailProbe}`;
|
||||
}
|
||||
return (
|
||||
`${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}${SEP}` +
|
||||
`${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}${SEP}${tailProbe}${SEP}` +
|
||||
`${jump.present ? '1' : '0'}${SEP}${jump.messageId ?? ''}${SEP}${jump.offset ?? 0}${SEP}` +
|
||||
`${jump.flash ? '1' : '0'}${SEP}${jump.returnToMessageId ?? ''}${SEP}` +
|
||||
`${jump.returnChannelId ?? ''}${SEP}${jump.returnGuildId ?? ''}${SEP}${jump.jumpType ?? ''}`
|
||||
@@ -243,7 +260,9 @@ function handleMessageFetchSuccess(
|
||||
channelId: string,
|
||||
messages: Array<WireMessage>,
|
||||
pageState: MessagePageState,
|
||||
cached: boolean,
|
||||
jump?: JumpOptions,
|
||||
tailProbe?: TailProbeContext,
|
||||
): void {
|
||||
Messages.handleLoadMessagesSuccess({
|
||||
channelId,
|
||||
@@ -252,13 +271,15 @@ function handleMessageFetchSuccess(
|
||||
isAfter: pageState.isAfter,
|
||||
hasMoreBefore: pageState.hasMoreBefore,
|
||||
hasMoreAfter: pageState.hasMoreAfter,
|
||||
cached: false,
|
||||
cached,
|
||||
jump,
|
||||
tailProbe: tailProbe != null,
|
||||
});
|
||||
ReadStates.handleLoadMessages({
|
||||
channelId,
|
||||
isAfter: pageState.isAfter,
|
||||
messages,
|
||||
tailProbeWatermarkId: tailProbe?.watermarkMessageId ?? null,
|
||||
});
|
||||
MessageReferences.handleMessagesFetchSuccess(channelId, messages);
|
||||
void requestMissingGuildMembers(channelId, messages);
|
||||
@@ -374,6 +395,30 @@ function applyMessageFetchCacheHit(
|
||||
}
|
||||
}
|
||||
|
||||
function isTailProbeApplicable(channelId: string, probe: TailProbeEpoch, after: string | null): boolean {
|
||||
const current = Messages.getMessages(channelId);
|
||||
const outcome = selectChannelMessagesTailProbeOutcome({
|
||||
probeGeneration: probe.loadGeneration,
|
||||
currentGeneration: current.loadGeneration,
|
||||
probeJumpTicket: probe.jumpTicket,
|
||||
currentJumpTicket: current.jumpTicket,
|
||||
ready: current.ready,
|
||||
hasMoreAfter: current.hasMoreAfter,
|
||||
anchorMessageId: after,
|
||||
newestLoadedMessageId: current.last()?.id ?? null,
|
||||
});
|
||||
if (outcome === 'apply') {
|
||||
return true;
|
||||
}
|
||||
logger.debug(`Discarding tail probe for channel ${channelId} (${outcome})`);
|
||||
Messages.handleTailProbeSettled({channelId});
|
||||
return false;
|
||||
}
|
||||
|
||||
function settleTailProbe(options: FetchMessagesOptions | undefined, settlement: TailProbeSettlement): void {
|
||||
options?.tailProbe?.onSettled(settlement);
|
||||
}
|
||||
|
||||
export async function fetchMessages(
|
||||
channelId: string,
|
||||
before: string | null,
|
||||
@@ -392,13 +437,16 @@ export async function fetchMessages(
|
||||
switch (preflightDecision.type) {
|
||||
case 'useInFlightRequest':
|
||||
logger.debug(`Using in-flight fetchMessages for channel ${channelId} (deduped)`);
|
||||
settleTailProbe(options, 'retry');
|
||||
return inFlight as Promise<Array<WireMessage>>;
|
||||
case 'blockForGate':
|
||||
logger.debug(`Skipping message fetch for gated channel ${channelId}`);
|
||||
Messages.handleLoadMessagesBlocked({channelId});
|
||||
settleTailProbe(options, 'retry');
|
||||
return [];
|
||||
case 'useCache':
|
||||
applyMessageFetchCacheHit(channelId, preflightDecision.cacheHit, before, after, limit, jump);
|
||||
settleTailProbe(options, 'retry');
|
||||
return [];
|
||||
case 'startFetch':
|
||||
break;
|
||||
@@ -409,19 +457,46 @@ export async function fetchMessages(
|
||||
forceFailure: DeveloperOptions.forceFailMessageLoads,
|
||||
});
|
||||
if (executionDecision.type === 'simulateFailure') {
|
||||
if (options?.tailProbe != null) {
|
||||
settleTailProbe(options, 'failed');
|
||||
return [];
|
||||
}
|
||||
return handleForcedMessageLoadFailure(channelId, jump);
|
||||
}
|
||||
Messages.handleLoadMessages({channelId, jump});
|
||||
Messages.handleLoadMessages({channelId, jump, tailProbe: options?.tailProbe != null});
|
||||
let probeEpoch: TailProbeEpoch | null = null;
|
||||
if (options?.tailProbe) {
|
||||
const started = Messages.getMessages(channelId);
|
||||
probeEpoch = {loadGeneration: started.loadGeneration, jumpTicket: started.jumpTicket};
|
||||
}
|
||||
const connectedAtRequest = GatewayConnection.isConnected;
|
||||
const epochAtRequest = GatewayConnection.connectionEpoch;
|
||||
try {
|
||||
const timeStart = Date.now();
|
||||
logger.debug(`Fetching messages for channel ${channelId}`);
|
||||
const messages = await requestChannelMessages(channelId, before, after, limit, jump);
|
||||
const cached = resolveMessageFetchWindowCached({
|
||||
connectedAtRequest,
|
||||
connectedAtResponse: GatewayConnection.isConnected,
|
||||
epochAtRequest,
|
||||
epochAtResponse: GatewayConnection.connectionEpoch,
|
||||
});
|
||||
if (probeEpoch != null && !isTailProbeApplicable(channelId, probeEpoch, after)) {
|
||||
settleTailProbe(options, 'retry');
|
||||
return [];
|
||||
}
|
||||
const pageState = calculateMessagePageState(channelId, before, after, limit, messages, jump);
|
||||
logger.info(`Fetched ${messages.length} messages for channel ${channelId}, took ${Date.now() - timeStart}ms`);
|
||||
handleMessageFetchSuccess(channelId, messages, pageState, jump);
|
||||
handleMessageFetchSuccess(channelId, messages, pageState, cached, jump, options?.tailProbe);
|
||||
settleTailProbe(options, 'applied');
|
||||
return messages;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch messages for channel ${channelId}:`, error);
|
||||
if (probeEpoch != null) {
|
||||
Messages.handleTailProbeSettled({channelId});
|
||||
settleTailProbe(options, 'failed');
|
||||
return [];
|
||||
}
|
||||
Messages.handleLoadMessagesFailure({channelId});
|
||||
if (options?.throwOnError) {
|
||||
throw error;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type MessageFetchPreflightInput,
|
||||
resolveMessageFetchExecutionDecision,
|
||||
resolveMessageFetchPreflightDecision,
|
||||
resolveMessageFetchWindowCached,
|
||||
selectMessageFetchExecutionDecision,
|
||||
selectMessageFetchPreflightDecision,
|
||||
transitionMessageFetchExecutionSnapshot,
|
||||
@@ -104,3 +105,31 @@ describe('messageFetchExecutionMachine', () => {
|
||||
expect(selectMessageFetchExecutionDecision(networkSnapshot)).toEqual({type: 'requestNetwork'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMessageFetchWindowCached', () => {
|
||||
function trust(overrides: Partial<Parameters<typeof resolveMessageFetchWindowCached>[0]> = {}) {
|
||||
return {
|
||||
connectedAtRequest: true,
|
||||
connectedAtResponse: true,
|
||||
epochAtRequest: 7,
|
||||
epochAtResponse: 7,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('trusts a window loaded inside one uninterrupted connection', () => {
|
||||
expect(resolveMessageFetchWindowCached(trust())).toBe(false);
|
||||
});
|
||||
|
||||
it('distrusts a window whose request left while the socket was down', () => {
|
||||
expect(resolveMessageFetchWindowCached(trust({connectedAtRequest: false}))).toBe(true);
|
||||
});
|
||||
|
||||
it('distrusts a window whose response landed while the socket was down', () => {
|
||||
expect(resolveMessageFetchWindowCached(trust({connectedAtResponse: false}))).toBe(true);
|
||||
});
|
||||
|
||||
it('distrusts a window whose connection epoch moved under it', () => {
|
||||
expect(resolveMessageFetchWindowCached(trust({epochAtResponse: 8}))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,3 +197,15 @@ export function selectMessageFetchExecutionDecision(
|
||||
export function resolveMessageFetchExecutionDecision(input: MessageFetchExecutionInput): MessageFetchExecutionDecision {
|
||||
return buildExecutionDecision(input);
|
||||
}
|
||||
|
||||
export interface MessageFetchWindowTrustInput {
|
||||
connectedAtRequest: boolean;
|
||||
connectedAtResponse: boolean;
|
||||
epochAtRequest: number;
|
||||
epochAtResponse: number;
|
||||
}
|
||||
|
||||
export function resolveMessageFetchWindowCached(input: MessageFetchWindowTrustInput): boolean {
|
||||
if (!input.connectedAtRequest || !input.connectedAtResponse) return true;
|
||||
return input.epochAtRequest !== input.epochAtResponse;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {SAVED_MESSAGES_PAGE_SIZE} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
@@ -31,8 +32,14 @@ interface SaveMessageRequest {
|
||||
message_id: string;
|
||||
}
|
||||
|
||||
async function requestSavedMessages(): Promise<Array<SavedMessageEntryWire>> {
|
||||
const response = await http.get<Array<SavedMessageEntryWire>>(Endpoints.USER_SAVED_MESSAGES);
|
||||
interface SavedMessagesFetchOptions {
|
||||
before?: string;
|
||||
}
|
||||
|
||||
async function requestSavedMessages(options: SavedMessagesFetchOptions = {}): Promise<Array<SavedMessageEntryWire>> {
|
||||
const response = await http.get<Array<SavedMessageEntryWire>>(Endpoints.USER_SAVED_MESSAGES, {
|
||||
query: {limit: SAVED_MESSAGES_PAGE_SIZE, ...options},
|
||||
});
|
||||
return response.body ?? [];
|
||||
}
|
||||
|
||||
@@ -78,20 +85,37 @@ function showMaxBookmarksModal(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function fetch(): Promise<Array<SavedMessageEntry>> {
|
||||
async function runSavedMessagesFetch(
|
||||
label: string,
|
||||
append: boolean,
|
||||
options: SavedMessagesFetchOptions = {},
|
||||
): Promise<Array<SavedMessageEntry>> {
|
||||
const requestId = SavedMessages.handleFetchPending();
|
||||
try {
|
||||
logger.debug('Fetching saved messages');
|
||||
const entries = savedMessageEntries(await requestSavedMessages());
|
||||
SavedMessages.fetchSuccess(entries);
|
||||
logger.debug(label);
|
||||
const entries = savedMessageEntries(await requestSavedMessages(options));
|
||||
SavedMessages.fetchSuccess(requestId, entries, append);
|
||||
logger.debug(`Successfully fetched ${entries.length} saved messages`);
|
||||
return entries;
|
||||
} catch (error) {
|
||||
SavedMessages.fetchError();
|
||||
logger.error('Failed to fetch saved messages:', error);
|
||||
SavedMessages.fetchError(requestId, append);
|
||||
logger.error(`${label} failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetch(): Promise<Array<SavedMessageEntry>> {
|
||||
return runSavedMessagesFetch('Fetching saved messages', false);
|
||||
}
|
||||
|
||||
export async function loadMore(): Promise<Array<SavedMessageEntry>> {
|
||||
const before = SavedMessages.getCursor();
|
||||
if (!before || !SavedMessages.getHasMore() || SavedMessages.getIsLoadingMore()) {
|
||||
return [];
|
||||
}
|
||||
return runSavedMessagesFetch(`Loading more saved messages before ${before}`, true, {before});
|
||||
}
|
||||
|
||||
export async function create(i18n: I18n, channelId: string, messageId: string): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Saving message ${messageId} from channel ${channelId}`);
|
||||
|
||||
@@ -15,13 +15,14 @@ import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils
|
||||
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {FlagCheckeredIcon, SparkleIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useMemo, useRef} from 'react';
|
||||
import {useCallback, useMemo, useRef} from 'react';
|
||||
|
||||
const YOU_VE_REACHED_THE_END_DESCRIPTOR = msg({
|
||||
message: "You've reached the end",
|
||||
@@ -37,6 +38,9 @@ interface MessageListPageProps {
|
||||
endStateDescription: string;
|
||||
renderActionButtons: (message: Message) => React.ReactNode;
|
||||
renderMissingMessage?: (message: Message) => React.ReactNode;
|
||||
hasMore?: boolean;
|
||||
isLoadingMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
}
|
||||
|
||||
export const MessageListPage = observer(
|
||||
@@ -49,6 +53,9 @@ export const MessageListPage = observer(
|
||||
endStateDescription,
|
||||
renderActionButtons,
|
||||
renderMissingMessage,
|
||||
hasMore = false,
|
||||
isLoadingMore = false,
|
||||
onLoadMore,
|
||||
}: MessageListPageProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const scrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
@@ -66,6 +73,16 @@ export const MessageListPage = observer(
|
||||
containerRef: scrollerRef,
|
||||
allowWhenInactive: true,
|
||||
});
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
const scrollPercentage = (target.scrollTop + target.offsetHeight) / target.scrollHeight;
|
||||
if (scrollPercentage > 0.8 && hasMore && !isLoadingMore) {
|
||||
onLoadMore?.();
|
||||
}
|
||||
},
|
||||
[hasMore, isLoadingMore, onLoadMore],
|
||||
);
|
||||
return (
|
||||
<div className={styles.container} data-flx="messaging.message-list-page.container">
|
||||
<ChannelHeader
|
||||
@@ -78,6 +95,7 @@ export const MessageListPage = observer(
|
||||
<NearViewportSurfaceContext.Provider value={resolveListPageScrollSurface}>
|
||||
<Scroller
|
||||
className={styles.scroller}
|
||||
onScroll={handleScroll}
|
||||
key="message-list-page-scroller"
|
||||
ref={scrollerRef}
|
||||
onCopy={onCopySelectedMessages}
|
||||
@@ -127,25 +145,32 @@ export const MessageListPage = observer(
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={styles.endState} data-flx="messaging.message-list-page.end-state">
|
||||
<div className={styles.endStateContent} data-flx="messaging.message-list-page.end-state-content">
|
||||
<FlagCheckeredIcon
|
||||
className={styles.endStateIcon}
|
||||
data-flx="messaging.message-list-page.end-state-icon"
|
||||
/>
|
||||
<div className={styles.endStateText} data-flx="messaging.message-list-page.end-state-text">
|
||||
<h3 className={styles.endStateTitle} data-flx="messaging.message-list-page.end-state-title">
|
||||
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
|
||||
</h3>
|
||||
<p
|
||||
className={styles.endStateDescription}
|
||||
data-flx="messaging.message-list-page.end-state-description"
|
||||
>
|
||||
{endStateDescription}
|
||||
</p>
|
||||
{isLoadingMore && (
|
||||
<div className={previewStyles.loadingState} data-flx="messaging.message-list-page.loading-state">
|
||||
<Spinner data-flx="messaging.message-list-page.spinner" />
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && !isLoadingMore && (
|
||||
<div className={styles.endState} data-flx="messaging.message-list-page.end-state">
|
||||
<div className={styles.endStateContent} data-flx="messaging.message-list-page.end-state-content">
|
||||
<FlagCheckeredIcon
|
||||
className={styles.endStateIcon}
|
||||
data-flx="messaging.message-list-page.end-state-icon"
|
||||
/>
|
||||
<div className={styles.endStateText} data-flx="messaging.message-list-page.end-state-text">
|
||||
<h3 className={styles.endStateTitle} data-flx="messaging.message-list-page.end-state-title">
|
||||
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
|
||||
</h3>
|
||||
<p
|
||||
className={styles.endStateDescription}
|
||||
data-flx="messaging.message-list-page.end-state-description"
|
||||
>
|
||||
{endStateDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Scroller>
|
||||
</NearViewportSurfaceContext.Provider>
|
||||
) : (
|
||||
|
||||
@@ -39,6 +39,8 @@ const THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR = msg({
|
||||
export const SavedMessagesPage = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const {savedMessages, missingSavedMessages, fetched} = SavedMessages;
|
||||
const hasMore = SavedMessages.getHasMore();
|
||||
const isLoadingMore = SavedMessages.getIsLoadingMore();
|
||||
useEffect(() => {
|
||||
if (!fetched) {
|
||||
SavedMessageCommands.fetch();
|
||||
@@ -91,6 +93,9 @@ export const SavedMessagesPage = observer(() => {
|
||||
emptyStateDescription={i18n._(BOOKMARK_MESSAGES_TO_SAVE_THEM_FOR_LATER_DESCRIPTOR)}
|
||||
endStateDescription={i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)}
|
||||
renderActionButtons={renderActionButtons}
|
||||
hasMore={hasMore}
|
||||
isLoadingMore={isLoadingMore}
|
||||
onLoadMore={SavedMessageCommands.loadMore}
|
||||
data-flx="messaging.saved-messages-page.message-list-page"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,8 @@ const readonlyBehaviorOverrides = {
|
||||
export const SavedMessagesContent = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const {savedMessages, missingSavedMessages, fetched} = SavedMessages;
|
||||
const hasMore = SavedMessages.getHasMore();
|
||||
const isLoadingMore = SavedMessages.getIsLoadingMore();
|
||||
const scrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
const resolveSavedScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
|
||||
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(savedMessages);
|
||||
@@ -79,6 +81,16 @@ export const SavedMessagesContent = observer(() => {
|
||||
goToMessage(channelId, messageId);
|
||||
InboxCommands.closeInboxAndFocusChannelTextarea(channelId);
|
||||
}, []);
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
const scrollPercentage = (target.scrollTop + target.offsetHeight) / target.scrollHeight;
|
||||
if (scrollPercentage > 0.8 && hasMore && !isLoadingMore) {
|
||||
SavedMessageCommands.loadMore();
|
||||
}
|
||||
},
|
||||
[hasMore, isLoadingMore],
|
||||
);
|
||||
if (!fetched) {
|
||||
return (
|
||||
<div className={previewStyles.emptyState} data-flx="messaging.saved-messages-content.div">
|
||||
@@ -110,6 +122,7 @@ export const SavedMessagesContent = observer(() => {
|
||||
<NearViewportSurfaceContext.Provider value={resolveSavedScrollSurface}>
|
||||
<Scroller
|
||||
className={styles.scroller}
|
||||
onScroll={handleScroll}
|
||||
key="saved-messages-scroller"
|
||||
ref={scrollerRef}
|
||||
onCopy={onCopySelectedMessages}
|
||||
@@ -189,22 +202,29 @@ export const SavedMessagesContent = observer(() => {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={previewStyles.endState} data-flx="messaging.saved-messages-content.div--7">
|
||||
<div className={previewStyles.endStateContent} data-flx="messaging.saved-messages-content.div--8">
|
||||
<FlagCheckeredIcon
|
||||
className={previewStyles.endStateIcon}
|
||||
data-flx="messaging.saved-messages-content.flag-checkered-icon"
|
||||
/>
|
||||
<div className={previewStyles.endStateTextContainer} data-flx="messaging.saved-messages-content.div--9">
|
||||
<h3 className={previewStyles.endStateTitle} data-flx="messaging.saved-messages-content.h3--2">
|
||||
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
|
||||
</h3>
|
||||
<p className={previewStyles.endStateDescription} data-flx="messaging.saved-messages-content.p--2">
|
||||
{i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)}
|
||||
</p>
|
||||
{isLoadingMore && (
|
||||
<div className={previewStyles.loadingState} data-flx="messaging.saved-messages-content.div--10">
|
||||
<Spinner data-flx="messaging.saved-messages-content.spinner--2" />
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && !isLoadingMore && (
|
||||
<div className={previewStyles.endState} data-flx="messaging.saved-messages-content.div--7">
|
||||
<div className={previewStyles.endStateContent} data-flx="messaging.saved-messages-content.div--8">
|
||||
<FlagCheckeredIcon
|
||||
className={previewStyles.endStateIcon}
|
||||
data-flx="messaging.saved-messages-content.flag-checkered-icon"
|
||||
/>
|
||||
<div className={previewStyles.endStateTextContainer} data-flx="messaging.saved-messages-content.div--9">
|
||||
<h3 className={previewStyles.endStateTitle} data-flx="messaging.saved-messages-content.h3--2">
|
||||
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
|
||||
</h3>
|
||||
<p className={previewStyles.endStateDescription} data-flx="messaging.saved-messages-content.p--2">
|
||||
{i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Scroller>
|
||||
</NearViewportSurfaceContext.Provider>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
import {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {UploadingAttachment} from '@app/features/messaging/models/UploadingAttachment';
|
||||
import {resolveChannelIncomingMessageDecision} from '@app/features/messaging/state/ChannelIncomingMessageStateMachine';
|
||||
import {resolveChannelMessagesLoadDecision} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
|
||||
import {
|
||||
resolveChannelMessagesLoadDecision,
|
||||
selectChannelMessagesLoadRestoresTrust,
|
||||
} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
|
||||
import MessageReactions from '@app/features/messaging/state/MessageReactions';
|
||||
import {mergeAscendingById} from '@app/features/messaging/utils/MessagePaginationUtils';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import type {JumpType} from '@fluxer/constants/src/JumpConstants';
|
||||
import {JumpTypes} from '@fluxer/constants/src/JumpConstants';
|
||||
@@ -48,6 +52,7 @@ interface LoadCompleteOptions {
|
||||
hasMoreBefore?: boolean;
|
||||
hasMoreAfter?: boolean;
|
||||
cached?: boolean;
|
||||
tailProbe?: boolean;
|
||||
}
|
||||
|
||||
type MessageInput = Message | WireMessage;
|
||||
@@ -281,6 +286,8 @@ class MessageBufferSegment {
|
||||
}
|
||||
}
|
||||
|
||||
let nextLoadGeneration = 0;
|
||||
|
||||
export class ChannelMessages {
|
||||
private static readonly channelCache = new Map<string, ChannelMessages>();
|
||||
private static readonly maxChannelsInMemory = 50;
|
||||
@@ -292,6 +299,8 @@ export class ChannelMessages {
|
||||
jumpDestinationId: string | null = null;
|
||||
jumpDestinationOffset = 0;
|
||||
jumpTicket = 1;
|
||||
loadGeneration = 0;
|
||||
probeLoading = false;
|
||||
hasJumped = false;
|
||||
landedAtLiveEdge = false;
|
||||
jumpHighlight = true;
|
||||
@@ -352,6 +361,10 @@ export class ChannelMessages {
|
||||
ChannelMessages.retainedChannelIds.delete(channelId);
|
||||
}
|
||||
|
||||
static isRetained(channelId: string): boolean {
|
||||
return ChannelMessages.retainedChannelIds.has(channelId);
|
||||
}
|
||||
|
||||
static dropBuffers(channelId: string): void {
|
||||
const instance = ChannelMessages.channelCache.get(channelId);
|
||||
if (!instance) return;
|
||||
@@ -605,9 +618,9 @@ export class ChannelMessages {
|
||||
}, true);
|
||||
}
|
||||
|
||||
merge(records: Array<Message>, prepend = false, clearBuffer = false): ChannelMessages {
|
||||
merge(records: Array<Message>, prepend = false, clearBuffer = false, ordered = false): ChannelMessages {
|
||||
return this.cloneAnd((draft) => {
|
||||
draft.mergeInto(records, prepend, clearBuffer);
|
||||
draft.mergeInto(records, prepend, clearBuffer, ordered);
|
||||
}, true);
|
||||
}
|
||||
|
||||
@@ -822,8 +835,18 @@ export class ChannelMessages {
|
||||
return this.cloneAnd({ready: true, cached: true}).merge([hydrateMessage(this, wire, 'preserve')]);
|
||||
}
|
||||
|
||||
beginProbeLoad(): ChannelMessages {
|
||||
return this.cloneAnd({loadGeneration: ++nextLoadGeneration, probeLoading: true});
|
||||
}
|
||||
|
||||
endProbeLoad(): ChannelMessages {
|
||||
if (!this.probeLoading) return this;
|
||||
return this.cloneAnd({probeLoading: false});
|
||||
}
|
||||
|
||||
beginLoad(jump?: JumpOptions): ChannelMessages {
|
||||
return this.cloneAnd({
|
||||
loadGeneration: ++nextLoadGeneration,
|
||||
loadingMore: true,
|
||||
hasJumped: jump != null,
|
||||
landedAtLiveEdge: jump?.present ?? false,
|
||||
@@ -845,6 +868,7 @@ export class ChannelMessages {
|
||||
hasMoreBefore = false,
|
||||
hasMoreAfter = false,
|
||||
cached = false,
|
||||
tailProbe = false,
|
||||
} = options;
|
||||
const records = [...windowMessages].reverse().map((m) => hydrateMessage(this, m, 'empty'));
|
||||
const loadDecision = resolveChannelMessagesLoadDecision({
|
||||
@@ -862,29 +886,40 @@ export class ChannelMessages {
|
||||
next = next.merge(unsent);
|
||||
}
|
||||
} else {
|
||||
next = this.merge(records, loadDecision.prepend, true);
|
||||
if (loadDecision.trimBottom) {
|
||||
next = this.merge(records, loadDecision.prepend, true, true);
|
||||
if (!tailProbe && loadDecision.trimBottom) {
|
||||
next = next.trimToWindow(true, false);
|
||||
} else if (loadDecision.trimTop) {
|
||||
} else if (!tailProbe && loadDecision.trimTop) {
|
||||
next = next.trimToWindow(false, true);
|
||||
}
|
||||
}
|
||||
const jumpPatch = tailProbe
|
||||
? {}
|
||||
: {
|
||||
jumpType: jump?.jumpType ?? JumpTypes.ANIMATED,
|
||||
jumpHighlight: jump?.flash ?? false,
|
||||
hasJumped: jump != null,
|
||||
landedAtLiveEdge: jump?.present ?? false,
|
||||
jumpDestinationId: jump?.messageId ?? null,
|
||||
jumpDestinationOffset: jump && jump.messageId != null && jump.offset != null ? jump.offset : 0,
|
||||
jumpTicket: jump ? next.jumpTicket + 1 : next.jumpTicket,
|
||||
jumpReturnMessageId: jump?.returnToMessageId ?? null,
|
||||
jumpReturnChannelId: jump?.returnToMessageId ? (jump.returnChannelId ?? this.channelId) : null,
|
||||
jumpReturnGuildId: jump?.returnToMessageId ? (jump.returnGuildId ?? null) : null,
|
||||
};
|
||||
const reachesLiveEdge = selectChannelMessagesLoadRestoresTrust({
|
||||
mode: loadDecision.mode,
|
||||
isAfter,
|
||||
hasMoreAfter,
|
||||
});
|
||||
next = next.cloneAnd({
|
||||
ready: true,
|
||||
loadingMore: false,
|
||||
jumpType: jump?.jumpType ?? JumpTypes.ANIMATED,
|
||||
jumpHighlight: jump?.flash ?? false,
|
||||
hasJumped: jump != null,
|
||||
landedAtLiveEdge: jump?.present ?? false,
|
||||
jumpDestinationId: jump?.messageId ?? null,
|
||||
jumpDestinationOffset: jump && jump.messageId != null && jump.offset != null ? jump.offset : 0,
|
||||
jumpTicket: jump ? next.jumpTicket + 1 : next.jumpTicket,
|
||||
jumpReturnMessageId: jump?.returnToMessageId ?? null,
|
||||
jumpReturnChannelId: jump?.returnToMessageId ? (jump.returnChannelId ?? this.channelId) : null,
|
||||
jumpReturnGuildId: jump?.returnToMessageId ? (jump.returnGuildId ?? null) : null,
|
||||
probeLoading: false,
|
||||
...jumpPatch,
|
||||
hasMoreBefore: loadDecision.preserveHasMoreBefore ? next.hasMoreBefore : hasMoreBefore,
|
||||
hasMoreAfter: loadDecision.preserveHasMoreAfter ? next.hasMoreAfter : hasMoreAfter,
|
||||
cached,
|
||||
cached: reachesLiveEdge ? cached : next.cached || cached,
|
||||
error: false,
|
||||
});
|
||||
return next;
|
||||
@@ -914,7 +949,7 @@ export class ChannelMessages {
|
||||
this.messageIndex = {};
|
||||
}
|
||||
|
||||
private mergeInto(incoming: Array<Message>, prepend = false, clearSideBuffer = false): void {
|
||||
private mergeInto(incoming: Array<Message>, prepend = false, clearSideBuffer = false, ordered = false): void {
|
||||
const newItems: Array<Message> = [];
|
||||
for (const msg of incoming) {
|
||||
const existing = this.messageIndex[msg.id];
|
||||
@@ -937,7 +972,11 @@ export class ChannelMessages {
|
||||
buffer.clear();
|
||||
}
|
||||
if (newItems.length === 0) return;
|
||||
this.messageList = prepend ? newItems.concat(this.messageList) : this.messageList.concat(newItems);
|
||||
if (prepend) {
|
||||
this.messageList = newItems.concat(this.messageList);
|
||||
return;
|
||||
}
|
||||
this.messageList = ordered ? mergeAscendingById(this.messageList, newItems) : this.messageList.concat(newItems);
|
||||
}
|
||||
|
||||
private cloneAnd(
|
||||
@@ -956,6 +995,8 @@ export class ChannelMessages {
|
||||
clone.jumpDestinationId = this.jumpDestinationId;
|
||||
clone.jumpDestinationOffset = this.jumpDestinationOffset;
|
||||
clone.jumpTicket = this.jumpTicket;
|
||||
clone.loadGeneration = this.loadGeneration;
|
||||
clone.probeLoading = this.probeLoading;
|
||||
clone.hasJumped = this.hasJumped;
|
||||
clone.landedAtLiveEdge = this.landedAtLiveEdge;
|
||||
clone.jumpHighlight = this.jumpHighlight;
|
||||
@@ -978,6 +1019,8 @@ export class ChannelMessages {
|
||||
clone.jumpDestinationOffset =
|
||||
patch.jumpDestinationOffset !== undefined ? patch.jumpDestinationOffset : this.jumpDestinationOffset;
|
||||
clone.jumpTicket = patch.jumpTicket !== undefined ? patch.jumpTicket : this.jumpTicket;
|
||||
clone.loadGeneration = patch.loadGeneration !== undefined ? patch.loadGeneration : this.loadGeneration;
|
||||
clone.probeLoading = 'probeLoading' in patch ? !!patch.probeLoading : this.probeLoading;
|
||||
clone.hasJumped = 'hasJumped' in patch ? !!patch.hasJumped : this.hasJumped;
|
||||
clone.landedAtLiveEdge = 'landedAtLiveEdge' in patch ? !!patch.landedAtLiveEdge : this.landedAtLiveEdge;
|
||||
clone.jumpHighlight = 'jumpHighlight' in patch ? !!patch.jumpHighlight : this.jumpHighlight;
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
resolveChannelMessagesWindowStatus,
|
||||
selectChannelMessagesFillerVisible,
|
||||
selectChannelMessagesLoadDecision,
|
||||
selectChannelMessagesLoadRestoresTrust,
|
||||
selectChannelMessagesSpacerHeight,
|
||||
selectChannelMessagesTailGapId,
|
||||
selectChannelMessagesTailProbeId,
|
||||
selectChannelMessagesTailProbeOutcome,
|
||||
selectChannelMessagesWindowBar,
|
||||
selectChannelMessagesWindowStatus,
|
||||
transitionChannelMessagesLoadSnapshot,
|
||||
@@ -389,3 +393,149 @@ describe('selectChannelMessagesFillerVisible', () => {
|
||||
expect(streamCases).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectChannelMessagesTailProbeId', () => {
|
||||
const ID = {older: '1519773906704011264', newer: '1519773906708205568'};
|
||||
|
||||
function tailInput(
|
||||
overrides: Partial<ChannelMessagesWindowInput> = {},
|
||||
tail: {
|
||||
loading?: boolean;
|
||||
newestLoadedMessageId?: string | null;
|
||||
knownLatestMessageId?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
const windowInput: ChannelMessagesWindowInput = {
|
||||
ready: true,
|
||||
loading: false,
|
||||
failed: false,
|
||||
messageCount: 12,
|
||||
hasMoreBefore: true,
|
||||
hasMoreAfter: false,
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
status: resolveChannelMessagesWindowStatus(windowInput),
|
||||
loading: tail.loading ?? windowInput.loading,
|
||||
newestLoadedMessageId: tail.newestLoadedMessageId === undefined ? ID.older : tail.newestLoadedMessageId,
|
||||
knownLatestMessageId: tail.knownLatestMessageId === undefined ? ID.newer : tail.knownLatestMessageId,
|
||||
};
|
||||
}
|
||||
|
||||
it('probes from the newest loaded message when the live edge is known to be ahead', () => {
|
||||
expect(selectChannelMessagesTailProbeId(tailInput())).toBe(ID.older);
|
||||
});
|
||||
|
||||
it('stays silent for a window that already advertises a newer page', () => {
|
||||
expect(selectChannelMessagesTailProbeId(tailInput({hasMoreAfter: true}))).toBeNull();
|
||||
});
|
||||
|
||||
it('stays silent for a failed window so a failed probe cannot re-arm itself', () => {
|
||||
expect(selectChannelMessagesTailProbeId(tailInput({failed: true}))).toBeNull();
|
||||
});
|
||||
|
||||
it('stays silent while a load is in flight', () => {
|
||||
expect(selectChannelMessagesTailProbeId(tailInput({loading: true}))).toBeNull();
|
||||
});
|
||||
|
||||
it('stays silent for a window that has not loaded a page yet', () => {
|
||||
expect(selectChannelMessagesTailProbeId(tailInput({ready: false}))).toBeNull();
|
||||
});
|
||||
|
||||
it('never probes an empty window, so a channel with no readable history cannot arm it', () => {
|
||||
expect(
|
||||
selectChannelMessagesTailProbeId(
|
||||
tailInput({messageCount: 0, hasMoreBefore: false}, {newestLoadedMessageId: null}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('stays silent when the watermark matches or trails the newest loaded message', () => {
|
||||
expect(selectChannelMessagesTailProbeId(tailInput({}, {knownLatestMessageId: ID.older}))).toBeNull();
|
||||
expect(
|
||||
selectChannelMessagesTailProbeId(
|
||||
tailInput({}, {newestLoadedMessageId: ID.newer, knownLatestMessageId: ID.older}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(selectChannelMessagesTailProbeId(tailInput({}, {knownLatestMessageId: null}))).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps reporting the tail gap while the probe it armed is in flight', () => {
|
||||
const inFlight = tailInput({loading: true});
|
||||
expect(selectChannelMessagesTailProbeId(inFlight)).toBeNull();
|
||||
expect(selectChannelMessagesTailGapId(inFlight)).toBe(ID.older);
|
||||
});
|
||||
|
||||
it('reports no tail gap once the newest loaded message reaches the watermark', () => {
|
||||
expect(selectChannelMessagesTailGapId(tailInput({}, {newestLoadedMessageId: ID.newer}))).toBeNull();
|
||||
});
|
||||
|
||||
it('still reports the gap on a deep window so auto-ack cannot run past it', () => {
|
||||
expect(selectChannelMessagesTailGapId(tailInput({messageCount: 200}))).toBe(ID.older);
|
||||
expect(selectChannelMessagesTailProbeId(tailInput({messageCount: 200}))).toBe(ID.older);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectChannelMessagesTailProbeOutcome', () => {
|
||||
const ID = {older: '1519773906704011264', newer: '1519773906708205568'};
|
||||
|
||||
function outcomeInput(overrides: Partial<Parameters<typeof selectChannelMessagesTailProbeOutcome>[0]> = {}) {
|
||||
return {
|
||||
probeGeneration: 4,
|
||||
currentGeneration: 4,
|
||||
probeJumpTicket: 2,
|
||||
currentJumpTicket: 2,
|
||||
ready: true,
|
||||
hasMoreAfter: false,
|
||||
anchorMessageId: ID.older,
|
||||
newestLoadedMessageId: ID.older,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('applies a probe onto the window it was issued against', () => {
|
||||
expect(selectChannelMessagesTailProbeOutcome(outcomeInput())).toBe('apply');
|
||||
});
|
||||
|
||||
it('discards without touching shared state once another load owns the window', () => {
|
||||
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({currentGeneration: 5}))).toBe('discard');
|
||||
});
|
||||
|
||||
it('releases a probe whose window a jump has taken out of the ready state', () => {
|
||||
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({ready: false}))).toBe('release');
|
||||
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({currentGeneration: 9, ready: false}))).toBe('discard');
|
||||
});
|
||||
|
||||
it('releases a probe whose tail moved under it', () => {
|
||||
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({newestLoadedMessageId: ID.newer}))).toBe('release');
|
||||
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({newestLoadedMessageId: null}))).toBe('release');
|
||||
});
|
||||
|
||||
it('releases a probe whose window started advertising a newer page', () => {
|
||||
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({hasMoreAfter: true}))).toBe('release');
|
||||
});
|
||||
|
||||
it('discards a probe whose window a cached jump took over without starting a load', () => {
|
||||
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({currentJumpTicket: 3}))).toBe('discard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectChannelMessagesLoadRestoresTrust', () => {
|
||||
it('trusts a window a replacement load rebuilt from scratch', () => {
|
||||
expect(selectChannelMessagesLoadRestoresTrust({mode: 'replace', isAfter: false, hasMoreAfter: false})).toBe(true);
|
||||
});
|
||||
|
||||
it('trusts a window whose tail merge caught up to the live edge', () => {
|
||||
expect(selectChannelMessagesLoadRestoresTrust({mode: 'mergeAfter', isAfter: true, hasMoreAfter: false})).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps distrusting a tail merge that stopped short of the live edge', () => {
|
||||
expect(selectChannelMessagesLoadRestoresTrust({mode: 'mergeAfter', isAfter: true, hasMoreAfter: true})).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps distrusting a page of older history', () => {
|
||||
expect(selectChannelMessagesLoadRestoresTrust({mode: 'mergeBefore', isAfter: false, hasMoreAfter: false})).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {compare as compareSnowflakes} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {assign, getInitialSnapshot, type SnapshotFrom, setup, transition} from 'xstate';
|
||||
|
||||
export interface ChannelMessagesLoadInput {
|
||||
@@ -281,6 +282,56 @@ export function selectChannelMessagesSpacerHeight(status: ChannelMessagesWindowS
|
||||
return status.olderPageAvailable || status.newerPageAvailable ? fillerHeight : 0;
|
||||
}
|
||||
|
||||
export interface ChannelMessagesTailInput {
|
||||
status: ChannelMessagesWindowStatus;
|
||||
loading: boolean;
|
||||
newestLoadedMessageId: string | null;
|
||||
knownLatestMessageId: string | null;
|
||||
}
|
||||
|
||||
export function selectChannelMessagesTailGapId(input: ChannelMessagesTailInput): string | null {
|
||||
if (input.status.phase !== 'stream' || input.status.retryVisible || input.status.newerPageAvailable) return null;
|
||||
if (input.newestLoadedMessageId == null || input.knownLatestMessageId == null) return null;
|
||||
if (compareSnowflakes(input.knownLatestMessageId, input.newestLoadedMessageId) <= 0) return null;
|
||||
return input.newestLoadedMessageId;
|
||||
}
|
||||
|
||||
export function selectChannelMessagesTailProbeId(input: ChannelMessagesTailInput): string | null {
|
||||
if (input.loading) return null;
|
||||
return selectChannelMessagesTailGapId(input);
|
||||
}
|
||||
|
||||
export function selectChannelMessagesLoadRestoresTrust(input: {
|
||||
mode: ChannelMessagesLoadMode;
|
||||
isAfter: boolean;
|
||||
hasMoreAfter: boolean;
|
||||
}): boolean {
|
||||
if (input.mode === 'replace') return true;
|
||||
return input.isAfter && !input.hasMoreAfter;
|
||||
}
|
||||
|
||||
export interface ChannelMessagesTailProbeResultInput {
|
||||
probeGeneration: number;
|
||||
currentGeneration: number;
|
||||
probeJumpTicket: number;
|
||||
currentJumpTicket: number;
|
||||
ready: boolean;
|
||||
hasMoreAfter: boolean;
|
||||
anchorMessageId: string | null;
|
||||
newestLoadedMessageId: string | null;
|
||||
}
|
||||
|
||||
export type ChannelMessagesTailProbeOutcome = 'apply' | 'release' | 'discard';
|
||||
|
||||
export function selectChannelMessagesTailProbeOutcome(
|
||||
input: ChannelMessagesTailProbeResultInput,
|
||||
): ChannelMessagesTailProbeOutcome {
|
||||
if (input.currentGeneration !== input.probeGeneration) return 'discard';
|
||||
if (input.currentJumpTicket !== input.probeJumpTicket) return 'discard';
|
||||
if (!input.ready || input.hasMoreAfter || input.newestLoadedMessageId !== input.anchorMessageId) return 'release';
|
||||
return 'apply';
|
||||
}
|
||||
|
||||
export function selectChannelMessagesWindowBar(status: ChannelMessagesWindowStatus): ChannelMessagesWindowBar {
|
||||
if (status.phase === 'placeholder') return 'none';
|
||||
if (status.phase === 'retry' || status.retryVisible) return 'retry';
|
||||
|
||||
@@ -201,7 +201,7 @@ class Messages {
|
||||
}
|
||||
|
||||
private hasLoadedPage(messages: ChannelMessages): boolean {
|
||||
return messages.ready && messages.length > 0;
|
||||
return messages.ready && messages.length > 0 && !messages.cached;
|
||||
}
|
||||
|
||||
shouldPreloadLatestPage(channelId: string): boolean {
|
||||
@@ -209,7 +209,9 @@ class Messages {
|
||||
return false;
|
||||
}
|
||||
const messages = ChannelMessages.get(channelId);
|
||||
return !messages || (messages.length === 0 && !messages.loadingMore && !messages.ready);
|
||||
if (!messages) return true;
|
||||
if (messages.loadingMore || ChannelMessages.isRetained(channelId)) return false;
|
||||
return messages.length === 0 ? !messages.ready : messages.cached;
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -286,15 +288,6 @@ class Messages {
|
||||
return true;
|
||||
}
|
||||
|
||||
@action
|
||||
handleResumed(): boolean {
|
||||
ChannelMessages.forEach((messages) => {
|
||||
this.commitMessages(messages.withPatch({ready: true}));
|
||||
});
|
||||
this.notifyChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
@action
|
||||
handleGatewayReady(): boolean {
|
||||
const selectedChannelId = SelectedChannel.currentChannelId;
|
||||
@@ -386,9 +379,10 @@ class Messages {
|
||||
if (!isNonGuildChannel && !guildExists) {
|
||||
return false;
|
||||
}
|
||||
const distrustedTailId = messages.ready && messages.cached ? (messages.last()?.id ?? null) : null;
|
||||
this.commitMessages(messages.withPatch({loadingMore: true}));
|
||||
this.notifyChange();
|
||||
MessageCommands.fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL);
|
||||
MessageCommands.fetchMessages(channelId, null, distrustedTailId, MAX_MESSAGES_PER_CHANNEL);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -449,9 +443,9 @@ class Messages {
|
||||
}
|
||||
|
||||
@action
|
||||
handleLoadMessages(action: {channelId: string; jump?: JumpOptions}): boolean {
|
||||
handleLoadMessages(action: {channelId: string; jump?: JumpOptions; tailProbe?: boolean}): boolean {
|
||||
const messages = ChannelMessages.getOrCreate(action.channelId);
|
||||
this.commitMessages(messages.beginLoad(action.jump));
|
||||
this.commitMessages(action.tailProbe ? messages.beginProbeLoad() : messages.beginLoad(action.jump));
|
||||
this.notifyChange();
|
||||
return false;
|
||||
}
|
||||
@@ -505,6 +499,7 @@ class Messages {
|
||||
hasMoreBefore?: boolean;
|
||||
hasMoreAfter?: boolean;
|
||||
cached?: boolean;
|
||||
tailProbe?: boolean;
|
||||
messages: Array<WireMessage>;
|
||||
}): boolean {
|
||||
const messages = ChannelMessages.getOrCreate(action.channelId).applyLoadedWindow({
|
||||
@@ -515,6 +510,7 @@ class Messages {
|
||||
hasMoreBefore: action.hasMoreBefore,
|
||||
hasMoreAfter: action.hasMoreAfter,
|
||||
cached: action.cached,
|
||||
tailProbe: action.tailProbe,
|
||||
});
|
||||
this.commitMessages(messages);
|
||||
this.notifyChange();
|
||||
@@ -529,6 +525,15 @@ class Messages {
|
||||
return false;
|
||||
}
|
||||
|
||||
@action
|
||||
handleTailProbeSettled(action: {channelId: string}): boolean {
|
||||
const messages = ChannelMessages.get(action.channelId);
|
||||
if (!messages?.probeLoading) return false;
|
||||
this.commitMessages(messages.endProbeLoad());
|
||||
this.notifyChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
@action
|
||||
handleLoadMessagesBlocked(action: {channelId: string}): boolean {
|
||||
const messages = ChannelMessages.getOrCreate(action.channelId);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {SavedMessageEntry} from '@app/features/messaging/models/SavedMessageEntry';
|
||||
import SavedMessages from '@app/features/messaging/state/SavedMessages';
|
||||
import {SAVED_MESSAGES_PAGE_SIZE} from '@fluxer/constants/src/LimitConstants';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/messaging/models/MessagingMessage', () => ({
|
||||
Message: class {
|
||||
readonly id: string;
|
||||
constructor(data: {id: string}) {
|
||||
this.id = data.id;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
function entryPage(startId: number, count: number): Array<SavedMessageEntry> {
|
||||
return Array.from({length: count}, (_unused, index) =>
|
||||
SavedMessageEntry.fromResponse({
|
||||
id: String(startId - index),
|
||||
channel_id: '10',
|
||||
message_id: String(startId - index),
|
||||
status: 'missing_permissions',
|
||||
message: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('SavedMessages pagination', () => {
|
||||
beforeEach(() => {
|
||||
SavedMessages.handleGatewayReady();
|
||||
});
|
||||
|
||||
it('reports more pages while a full page comes back and tracks the oldest entry', () => {
|
||||
const requestId = SavedMessages.handleFetchPending();
|
||||
SavedMessages.fetchSuccess(requestId, entryPage(1000, SAVED_MESSAGES_PAGE_SIZE), false);
|
||||
expect(SavedMessages.getHasMore()).toBe(true);
|
||||
expect(SavedMessages.getIsLoadingMore()).toBe(false);
|
||||
expect(SavedMessages.getCursor()).toBe(String(1000 - (SAVED_MESSAGES_PAGE_SIZE - 1)));
|
||||
});
|
||||
|
||||
it('appends the next page instead of replacing the loaded one', () => {
|
||||
const first = SavedMessages.handleFetchPending();
|
||||
SavedMessages.fetchSuccess(first, entryPage(1000, SAVED_MESSAGES_PAGE_SIZE), false);
|
||||
const second = SavedMessages.handleFetchPending();
|
||||
SavedMessages.fetchSuccess(second, entryPage(900, 10), true);
|
||||
expect(SavedMessages.getMissingEntries()).toHaveLength(SAVED_MESSAGES_PAGE_SIZE + 10);
|
||||
expect(SavedMessages.getHasMore()).toBe(false);
|
||||
expect(SavedMessages.getCursor()).toBe('891');
|
||||
});
|
||||
|
||||
it('ignores a page that a newer request has superseded', () => {
|
||||
const stale = SavedMessages.handleFetchPending();
|
||||
SavedMessages.handleFetchPending();
|
||||
SavedMessages.fetchSuccess(stale, entryPage(1000, 5), false);
|
||||
expect(SavedMessages.fetched).toBe(false);
|
||||
expect(SavedMessages.getMissingEntries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the loaded pages when loading more fails', () => {
|
||||
const first = SavedMessages.handleFetchPending();
|
||||
SavedMessages.fetchSuccess(first, entryPage(1000, SAVED_MESSAGES_PAGE_SIZE), false);
|
||||
const second = SavedMessages.handleFetchPending();
|
||||
SavedMessages.fetchError(second, true);
|
||||
expect(SavedMessages.getMissingEntries()).toHaveLength(SAVED_MESSAGES_PAGE_SIZE);
|
||||
expect(SavedMessages.getIsLoadingMore()).toBe(false);
|
||||
expect(SavedMessages.fetched).toBe(true);
|
||||
});
|
||||
|
||||
it('clears everything when the first page fails', () => {
|
||||
const first = SavedMessages.handleFetchPending();
|
||||
SavedMessages.fetchSuccess(first, entryPage(1000, 5), false);
|
||||
const retry = SavedMessages.handleFetchPending();
|
||||
SavedMessages.fetchError(retry, false);
|
||||
expect(SavedMessages.getMissingEntries()).toHaveLength(0);
|
||||
expect(SavedMessages.fetched).toBe(false);
|
||||
expect(SavedMessages.getCursor()).toBeNull();
|
||||
expect(SavedMessages.getHasMore()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,23 @@
|
||||
|
||||
import {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import type {SavedMessageEntry, SavedMessageMissingEntry} from '@app/features/messaging/models/SavedMessageEntry';
|
||||
import {SAVED_MESSAGES_PAGE_SIZE} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {Channel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {makeAutoObservable} from 'mobx';
|
||||
|
||||
function byIdDescending(a: {id: string}, b: {id: string}): number {
|
||||
return b.id > a.id ? 1 : a.id > b.id ? -1 : 0;
|
||||
}
|
||||
|
||||
class SavedMessages {
|
||||
savedMessages: Array<Message> = [];
|
||||
missingSavedMessages: Array<SavedMessageMissingEntry> = [];
|
||||
fetched = false;
|
||||
hasMore = true;
|
||||
isLoadingMore = false;
|
||||
cursor: string | null = null;
|
||||
private fetchGeneration = 0;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -26,27 +35,74 @@ class SavedMessages {
|
||||
return this.missingSavedMessages.slice();
|
||||
}
|
||||
|
||||
fetchSuccess(entries: ReadonlyArray<SavedMessageEntry>): void {
|
||||
this.savedMessages = entries
|
||||
getHasMore(): boolean {
|
||||
return this.hasMore;
|
||||
}
|
||||
|
||||
getIsLoadingMore(): boolean {
|
||||
return this.isLoadingMore;
|
||||
}
|
||||
|
||||
getCursor(): string | null {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
handleFetchPending(): number {
|
||||
this.isLoadingMore = true;
|
||||
this.fetchGeneration++;
|
||||
return this.fetchGeneration;
|
||||
}
|
||||
|
||||
fetchSuccess(requestId: number, entries: ReadonlyArray<SavedMessageEntry>, append: boolean): void {
|
||||
if (requestId !== this.fetchGeneration) return;
|
||||
const available = entries
|
||||
.filter((entry) => entry.status === 'available' && entry.message)
|
||||
.map((entry) => entry.message!)
|
||||
.sort((a, b) => (b.id > a.id ? 1 : a.id > b.id ? -1 : 0));
|
||||
this.missingSavedMessages = entries
|
||||
.map((entry) => entry.message!);
|
||||
const missing = entries
|
||||
.filter((entry) => entry.status === 'missing_permissions' || entry.message === null)
|
||||
.map((entry) => entry.toMissingEntry());
|
||||
if (append) {
|
||||
const knownMessageIds = new Set(this.savedMessages.map((message) => message.id));
|
||||
const knownMissingIds = new Set(this.missingSavedMessages.map((entry) => entry.id));
|
||||
this.savedMessages = [
|
||||
...this.savedMessages,
|
||||
...available.filter((message) => !knownMessageIds.has(message.id)),
|
||||
].sort(byIdDescending);
|
||||
this.missingSavedMessages = [
|
||||
...this.missingSavedMessages,
|
||||
...missing.filter((entry) => !knownMissingIds.has(entry.id)),
|
||||
];
|
||||
} else {
|
||||
this.savedMessages = available.sort(byIdDescending);
|
||||
this.missingSavedMessages = missing;
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
this.cursor = entries[entries.length - 1].messageId;
|
||||
}
|
||||
this.hasMore = entries.length === SAVED_MESSAGES_PAGE_SIZE;
|
||||
this.isLoadingMore = false;
|
||||
this.fetched = true;
|
||||
}
|
||||
|
||||
fetchError(): void {
|
||||
fetchError(requestId: number, append: boolean): void {
|
||||
if (requestId !== this.fetchGeneration) return;
|
||||
this.isLoadingMore = false;
|
||||
if (append) return;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.savedMessages = [];
|
||||
this.missingSavedMessages = [];
|
||||
this.fetched = false;
|
||||
this.hasMore = true;
|
||||
this.isLoadingMore = false;
|
||||
this.cursor = null;
|
||||
}
|
||||
|
||||
handleGatewayReady(): void {
|
||||
this.savedMessages = [];
|
||||
this.missingSavedMessages = [];
|
||||
this.fetched = false;
|
||||
this.reset();
|
||||
this.fetchGeneration++;
|
||||
}
|
||||
|
||||
handleChannelDelete(channel: Channel): void {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {calculateAroundPaginationState, getAroundWindowCounts} from './MessagePaginationUtils';
|
||||
import {calculateAroundPaginationState, getAroundWindowCounts, mergeAscendingById} from './MessagePaginationUtils';
|
||||
|
||||
describe('MessagePaginationUtils', () => {
|
||||
it('splits around windows with the newer side receiving the extra item for even limits', () => {
|
||||
@@ -42,3 +42,30 @@ describe('MessagePaginationUtils', () => {
|
||||
expect(state.hasMoreAfter).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeAscendingById', () => {
|
||||
const ID = {a: '1519773906704011264', b: '1519773906708205568', c: '1519773906712399872'};
|
||||
|
||||
it('appends when every incoming message is newer than the tail', () => {
|
||||
const existing = [{id: ID.a}];
|
||||
expect(mergeAscendingById(existing, [{id: ID.b}, {id: ID.c}])).toEqual([{id: ID.a}, {id: ID.b}, {id: ID.c}]);
|
||||
});
|
||||
|
||||
it('returns the existing list untouched for an empty page', () => {
|
||||
const existing = [{id: ID.a}];
|
||||
expect(mergeAscendingById(existing, [])).toBe(existing);
|
||||
});
|
||||
|
||||
it('interleaves a recovered message that is older than a live message already at the tail', () => {
|
||||
expect(mergeAscendingById([{id: ID.a}, {id: ID.c}], [{id: ID.b}])).toEqual([{id: ID.a}, {id: ID.b}, {id: ID.c}]);
|
||||
});
|
||||
|
||||
it('keeps the whole page in order when it spans a live message already at the tail', () => {
|
||||
expect(mergeAscendingById([{id: ID.a}, {id: ID.b}], [{id: ID.c}])).toEqual([{id: ID.a}, {id: ID.b}, {id: ID.c}]);
|
||||
expect(mergeAscendingById([{id: ID.b}], [{id: ID.a}, {id: ID.c}])).toEqual([{id: ID.a}, {id: ID.b}, {id: ID.c}]);
|
||||
});
|
||||
|
||||
it('appends onto an empty window', () => {
|
||||
expect(mergeAscendingById([], [{id: ID.a}])).toEqual([{id: ID.a}]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {compare as compareSnowflakes} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
|
||||
export interface AroundWindowCounts {
|
||||
newer: number;
|
||||
older: number;
|
||||
@@ -49,3 +51,32 @@ export function calculateAroundPaginationState(input: AroundPaginationStateInput
|
||||
isAtKnownLatest,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAscendingById<T extends {id: string}>(existing: Array<T>, incoming: Array<T>): Array<T> {
|
||||
if (incoming.length === 0) return existing;
|
||||
const tail = existing[existing.length - 1];
|
||||
if (tail == null || compareSnowflakes(incoming[0].id, tail.id) > 0) {
|
||||
return existing.concat(incoming);
|
||||
}
|
||||
const merged: Array<T> = [];
|
||||
let left = 0;
|
||||
let right = 0;
|
||||
while (left < existing.length && right < incoming.length) {
|
||||
if (compareSnowflakes(incoming[right].id, existing[left].id) < 0) {
|
||||
merged.push(incoming[right]);
|
||||
right += 1;
|
||||
} else {
|
||||
merged.push(existing[left]);
|
||||
left += 1;
|
||||
}
|
||||
}
|
||||
while (left < existing.length) {
|
||||
merged.push(existing[left]);
|
||||
left += 1;
|
||||
}
|
||||
while (right < incoming.length) {
|
||||
merged.push(incoming[right]);
|
||||
right += 1;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -92,14 +92,64 @@ describe('ReadStates unread invariant', () => {
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
||||
});
|
||||
|
||||
it('clears a stale unread once the server walks the last message id back', () => {
|
||||
it('ignores a passive update that walks the last message id back', () => {
|
||||
const {channelId} = seedReadChannel();
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.ack}, 'guild-1');
|
||||
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
||||
});
|
||||
|
||||
it('still lets its own probe lower a watermark a passive update raised', () => {
|
||||
const {channelId} = seedReadChannel();
|
||||
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
||||
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: [], tailProbeWatermarkId: ID.newer});
|
||||
expect(ReadStates.lastMessageId(channelId)).toBe(ID.ack);
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(false);
|
||||
expect(ReadStates.getUnreadCount(channelId)).toBe(0);
|
||||
expect(ReadStates.getVisualUnreadMessageId(channelId)).toBeNull();
|
||||
});
|
||||
|
||||
it('lowers a watermark its own probe finds nothing behind', () => {
|
||||
const {channelId, state} = seedReadChannel();
|
||||
state.lastMessageId = ID.newer;
|
||||
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
|
||||
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: [], tailProbeWatermarkId: ID.newer});
|
||||
expect(ReadStates.lastMessageId(channelId)).toBe(ID.ack);
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a watermark that is ahead when an ordinary after page comes back empty', () => {
|
||||
const {channelId, state} = seedReadChannel();
|
||||
state.lastMessageId = ID.newer;
|
||||
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
|
||||
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: []});
|
||||
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
|
||||
});
|
||||
|
||||
it('keeps a watermark that advanced while its own probe was in flight', () => {
|
||||
const {channelId, state} = seedReadChannel();
|
||||
state.lastMessageId = ID.newer;
|
||||
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
|
||||
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: [], tailProbeWatermarkId: ID.ack});
|
||||
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
|
||||
});
|
||||
|
||||
it('keeps a watermark that is ahead when the page was not an after page', () => {
|
||||
const {channelId, state} = seedReadChannel();
|
||||
state.lastMessageId = ID.newer;
|
||||
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
|
||||
ReadStates.handleLoadMessages({channelId, messages: [], tailProbeWatermarkId: ID.newer});
|
||||
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
|
||||
});
|
||||
|
||||
it('keeps a watermark that is ahead when the window is not at the live edge', () => {
|
||||
const {channelId, state} = seedReadChannel();
|
||||
hasNewestMessages = false;
|
||||
state.lastMessageId = ID.newer;
|
||||
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
|
||||
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: [], tailProbeWatermarkId: ID.newer});
|
||||
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
|
||||
});
|
||||
|
||||
it('anchors the divider when a window is loaded whose ack sits outside it', () => {
|
||||
|
||||
@@ -439,7 +439,12 @@ class ReadStates {
|
||||
});
|
||||
}
|
||||
|
||||
handleLoadMessages(action: {channelId: string; isAfter?: boolean; messages: Array<WireMessage>}): void {
|
||||
handleLoadMessages(action: {
|
||||
channelId: string;
|
||||
isAfter?: boolean;
|
||||
messages: Array<WireMessage>;
|
||||
tailProbeWatermarkId?: string | null;
|
||||
}): void {
|
||||
const state = this.get(action.channelId);
|
||||
state.messagesLoaded = true;
|
||||
const messages = Messages.getMessages(action.channelId);
|
||||
@@ -448,6 +453,17 @@ class ReadStates {
|
||||
state.lastMessageId = newestMessage.id;
|
||||
}
|
||||
const landedOnNewestWindow = messages.hasNewestMessages();
|
||||
if (
|
||||
action.isAfter &&
|
||||
action.tailProbeWatermarkId != null &&
|
||||
action.tailProbeWatermarkId === state.lastMessageId &&
|
||||
action.messages.length === 0 &&
|
||||
landedOnNewestWindow &&
|
||||
newestMessage != null &&
|
||||
isNewerMessageId(state.lastMessageId, newestMessage.id)
|
||||
) {
|
||||
state.lastMessageId = newestMessage.id;
|
||||
}
|
||||
const landedOnAck = state.ackMessageId != null && messages.jumpDestinationId === state.ackMessageId;
|
||||
if (state.hasUnread() || landedOnNewestWindow || landedOnAck) {
|
||||
state.rebuild();
|
||||
@@ -583,7 +599,7 @@ class ReadStates {
|
||||
changed = state.guildId !== guildId;
|
||||
state.storedGuildId = guildId;
|
||||
}
|
||||
if (lastMessageId !== state.lastMessageId) {
|
||||
if (isNewerMessageId(lastMessageId, state.lastMessageId)) {
|
||||
state.lastMessageId = lastMessageId;
|
||||
changed = true;
|
||||
this.clearUnreadStateIfRead(state);
|
||||
|
||||
@@ -428,6 +428,7 @@
|
||||
max-inline-size: none;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
unicode-bidi: plaintext;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
@@ -505,6 +506,7 @@
|
||||
font-weight: 400;
|
||||
color: var(--message-timestamp-color);
|
||||
line-height: var(--message-line-height);
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.messageTimestamp {
|
||||
@@ -557,6 +559,7 @@
|
||||
font-size: var(--message-timestamp-compact-font-size);
|
||||
font-weight: 500;
|
||||
line-height: var(--message-line-height);
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.messageTimestampHover {
|
||||
@@ -948,6 +951,7 @@
|
||||
white-space: nowrap;
|
||||
max-width: 30%;
|
||||
vertical-align: baseline;
|
||||
unicode-bidi: plaintext;
|
||||
font-weight: 500;
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const MESSAGE_CSS = readFileSync(fileURLToPath(new URL('./Message.module.css', import.meta.url)), 'utf8');
|
||||
|
||||
const ISOLATED_NAMES = ['.messageUsername', '.repliedUsername'];
|
||||
const ISOLATED_TIMESTAMPS = [
|
||||
'.messageTimestamp',
|
||||
'.messageTimestampCompact',
|
||||
'.messageTimestampHover',
|
||||
'.messageTimestampCompactHover',
|
||||
];
|
||||
|
||||
function rules(css: string): Array<{selector: string; declarations: string}> {
|
||||
const source = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const out: Array<{selector: string; declarations: string}> = [];
|
||||
const chain: Array<string> = [];
|
||||
let buffer = '';
|
||||
for (const char of source) {
|
||||
if (char === '{') {
|
||||
chain.push(buffer.split(/[;}]/).pop()?.trim().replace(/\s+/g, ' ') ?? '');
|
||||
buffer = '';
|
||||
} else if (char === '}') {
|
||||
if (chain.length > 0) {
|
||||
out.push({selector: chain.join(' '), declarations: buffer});
|
||||
chain.pop();
|
||||
}
|
||||
buffer = '';
|
||||
} else {
|
||||
buffer += char;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function declaredBidi(className: string): Array<string> {
|
||||
const selectorPattern = new RegExp(`\\${className}(?![\\w-])`);
|
||||
const out: Array<string> = [];
|
||||
for (const rule of rules(MESSAGE_CSS)) {
|
||||
if (!rule.selector.split(',').some((selector) => selectorPattern.test(selector))) continue;
|
||||
const match = rule.declarations.match(/unicode-bidi\s*:\s*([^;]+)/);
|
||||
if (match) out.push(match[1].trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('message bidi isolation', () => {
|
||||
it.each(ISOLATED_NAMES)('resolves %s in its own bidi paragraph', (className) => {
|
||||
expect(declaredBidi(className)).toContain('plaintext');
|
||||
});
|
||||
|
||||
it.each(ISOLATED_TIMESTAMPS)('keeps %s out of a neighbouring right-to-left run', (className) => {
|
||||
expect(declaredBidi(className)).toContain('isolate');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {scopePortalHostToDocument} from '@app/features/ui/overlay/PortalHostContext';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('scopePortalHostToDocument', () => {
|
||||
it('keeps a host that belongs to the scoped document', () => {
|
||||
const host = document.createElement('div');
|
||||
expect(scopePortalHostToDocument(host, document)).toBe(host);
|
||||
});
|
||||
|
||||
it('drops a host owned by another document', () => {
|
||||
const popoutDocument = document.implementation.createHTMLDocument('popout');
|
||||
const popoutHost = popoutDocument.createElement('div');
|
||||
expect(scopePortalHostToDocument(popoutHost, document)).toBeNull();
|
||||
expect(scopePortalHostToDocument(popoutHost, popoutDocument)).toBe(popoutHost);
|
||||
});
|
||||
|
||||
it('passes a missing host through', () => {
|
||||
expect(scopePortalHostToDocument(null, document)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -39,3 +39,8 @@ export function usePortalHost(): PortalHostElement {
|
||||
export function resolvePortalHost(host: PortalHostElement): HTMLElement {
|
||||
return host ?? document.body;
|
||||
}
|
||||
|
||||
export function scopePortalHostToDocument(host: PortalHostElement, scopeDocument: Document): PortalHostElement {
|
||||
if (host == null) return null;
|
||||
return host.ownerDocument === scopeDocument ? host : null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {useAntiShiftFloating} from '@app/features/app/hooks/useAntiShiftFloating';
|
||||
import {shouldDisableAutofocusOnMobile} from '@app/features/platform/utils/AutofocusUtils';
|
||||
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
|
||||
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
|
||||
import {scopePortalHostToDocument, usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
|
||||
import {type Popout, PopoutKeyContext, type PopoutReferenceRect} from '@app/features/ui/popover';
|
||||
import {PopoutResizePositionContext} from '@app/features/ui/popover/PopoutResizePositionContext';
|
||||
import {getPopoutFocusManagerInsideElements} from '@app/features/ui/popover/PopoverFocusManagerUtils';
|
||||
@@ -186,6 +186,7 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
|
||||
);
|
||||
const {
|
||||
ref: popoutRef,
|
||||
setFloating,
|
||||
state,
|
||||
style,
|
||||
beginManualPositioning,
|
||||
@@ -206,7 +207,7 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
|
||||
useLayoutEffect(() => {
|
||||
focusRefs.setReference(target);
|
||||
}, [focusRefs, target]);
|
||||
const mergedPopoutRef = useMergeRefs([popoutRef, focusRefs.setFloating]);
|
||||
const mergedPopoutRef = useMergeRefs([setFloating, focusRefs.setFloating]);
|
||||
const prefersReducedMotion = Accessibility.useReducedMotion;
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [targetInDOM, setTargetInDOM] = useState(() => ownerDocument.contains(target));
|
||||
@@ -426,10 +427,11 @@ interface PopoutsProps {
|
||||
|
||||
export const Popouts: React.FC<PopoutsProps> = observer(({ownerDocument}) => {
|
||||
const prevPopoutKeysRef = useRef<Set<string>>(new Set());
|
||||
const portalHost = usePortalHost();
|
||||
const activePortalHost = usePortalHost();
|
||||
let scopeDocument = document;
|
||||
if (portalHost != null) scopeDocument = portalHost.ownerDocument;
|
||||
if (activePortalHost != null) scopeDocument = activePortalHost.ownerDocument;
|
||||
if (ownerDocument != null) scopeDocument = ownerDocument;
|
||||
const portalHost = scopePortalHostToDocument(activePortalHost, scopeDocument);
|
||||
const popouts = PopoutState.getPopouts(scopeDocument);
|
||||
const topPopout = popouts.length ? popouts[popouts.length - 1] : null;
|
||||
const needsBackdrop = Boolean(topPopout && !topPopout.disableBackdrop);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const IDLE_DURATION_MS = 600_000;
|
||||
const IDLE_CHECK_INTERVAL_MS = 30_000;
|
||||
|
||||
vi.mock('@app/features/platform/types/Env', () => ({
|
||||
IS_DEV: false,
|
||||
IS_PROD: true,
|
||||
MODE: 'test',
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/presence/state/LocalPresence', () => ({
|
||||
default: {updatePresence: vi.fn()},
|
||||
}));
|
||||
|
||||
async function loadIdle(getSystemIdleTimeMs?: () => Promise<number>) {
|
||||
vi.resetModules();
|
||||
if (getSystemIdleTimeMs) {
|
||||
Object.defineProperty(window, 'electron', {value: {getSystemIdleTimeMs}, configurable: true, writable: true});
|
||||
} else {
|
||||
Reflect.deleteProperty(window as unknown as Record<string, unknown>, 'electron');
|
||||
}
|
||||
const {default: Idle} = await import('@app/features/ui/state/Idle');
|
||||
return Idle;
|
||||
}
|
||||
|
||||
describe('Idle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window as unknown as Record<string, unknown>, 'electron');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('goes idle once nothing has reported activity for the idle duration', async () => {
|
||||
const Idle = await loadIdle();
|
||||
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS - IDLE_CHECK_INTERVAL_MS);
|
||||
expect(Idle.isIdle()).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
|
||||
expect(Idle.isIdle()).toBe(true);
|
||||
});
|
||||
|
||||
it('stays active while the app keeps seeing input', async () => {
|
||||
const Idle = await loadIdle();
|
||||
for (let elapsed = 0; elapsed < IDLE_DURATION_MS * 2; elapsed += IDLE_CHECK_INTERVAL_MS) {
|
||||
Idle.recordActivity();
|
||||
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
|
||||
}
|
||||
expect(Idle.isIdle()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays active while the app sees input even when the system idle clock keeps climbing', async () => {
|
||||
const startedAt = Date.now();
|
||||
const Idle = await loadIdle(async () => Date.now() - startedAt);
|
||||
for (let elapsed = 0; elapsed < IDLE_DURATION_MS * 2; elapsed += IDLE_CHECK_INTERVAL_MS) {
|
||||
Idle.recordActivity();
|
||||
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
|
||||
}
|
||||
expect(Idle.isIdle()).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves idle as soon as the app sees input again', async () => {
|
||||
const startedAt = Date.now();
|
||||
const Idle = await loadIdle(async () => Date.now() - startedAt);
|
||||
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS);
|
||||
expect(Idle.isIdle()).toBe(true);
|
||||
Idle.recordActivity();
|
||||
expect(Idle.isIdle()).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
|
||||
expect(Idle.isIdle()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays active while the system reports input the app never sees', async () => {
|
||||
const Idle = await loadIdle(async () => 0);
|
||||
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS * 2);
|
||||
expect(Idle.isIdle()).toBe(false);
|
||||
});
|
||||
|
||||
it('goes idle when neither the app nor the system reports activity', async () => {
|
||||
const startedAt = Date.now();
|
||||
const Idle = await loadIdle(async () => Date.now() - startedAt);
|
||||
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS);
|
||||
expect(Idle.isIdle()).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to app activity when the system idle clock is unreadable', async () => {
|
||||
const Idle = await loadIdle(async () => Number.NaN);
|
||||
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS);
|
||||
expect(Idle.isIdle()).toBe(true);
|
||||
Idle.recordActivity();
|
||||
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
|
||||
expect(Idle.isIdle()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -30,11 +30,11 @@ function normalizeIdleTimeMs(value: number): number | null {
|
||||
|
||||
class Idle {
|
||||
idle = false;
|
||||
private lastActivityTime = Date.now();
|
||||
private lastLocalActivityTime = Date.now();
|
||||
private lastSystemActivityTime = 0;
|
||||
private checkInterval: NodeJS.Timeout | null = null;
|
||||
private systemIdleCheckInFlight = false;
|
||||
private lastSystemIdleFailureAt = 0;
|
||||
private activityVersion = 0;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -56,16 +56,15 @@ class Idle {
|
||||
}
|
||||
|
||||
recordActivity(): void {
|
||||
this.lastActivityTime = Date.now();
|
||||
this.activityVersion++;
|
||||
this.lastLocalActivityTime = Date.now();
|
||||
if (this.idle) {
|
||||
this.applyIdleState(false);
|
||||
}
|
||||
}
|
||||
|
||||
markBackground(): void {
|
||||
this.lastActivityTime = 0;
|
||||
this.activityVersion++;
|
||||
this.lastLocalActivityTime = 0;
|
||||
this.lastSystemActivityTime = 0;
|
||||
this.applyIdleState(true);
|
||||
}
|
||||
|
||||
@@ -74,16 +73,24 @@ class Idle {
|
||||
}
|
||||
|
||||
getIdleSince(): number {
|
||||
return this.idle ? this.lastActivityTime : 0;
|
||||
return this.idle ? this.getLastActivityTime() : 0;
|
||||
}
|
||||
|
||||
getInactiveDurationMs(now = Date.now()): number {
|
||||
return Math.max(0, now - this.lastActivityTime);
|
||||
return Math.max(0, now - this.getLastActivityTime());
|
||||
}
|
||||
|
||||
private getLastActivityTime(): number {
|
||||
return Math.max(this.lastLocalActivityTime, this.lastSystemActivityTime);
|
||||
}
|
||||
|
||||
private updateIdleState(): void {
|
||||
const desktopIdleApi = getDesktopIdleApi();
|
||||
if (desktopIdleApi && Date.now() - this.lastSystemIdleFailureAt >= SYSTEM_IDLE_RETRY_DELAY_MS) {
|
||||
if (
|
||||
desktopIdleApi &&
|
||||
!this.systemIdleCheckInFlight &&
|
||||
Date.now() - this.lastSystemIdleFailureAt >= SYSTEM_IDLE_RETRY_DELAY_MS
|
||||
) {
|
||||
void this.updateIdleStateFromSystem(desktopIdleApi);
|
||||
return;
|
||||
}
|
||||
@@ -96,29 +103,20 @@ class Idle {
|
||||
}
|
||||
|
||||
private async updateIdleStateFromSystem(desktopIdleApi: Required<DesktopIdleApi>): Promise<void> {
|
||||
if (this.systemIdleCheckInFlight) return;
|
||||
this.systemIdleCheckInFlight = true;
|
||||
const activityVersion = this.activityVersion;
|
||||
const requestedAt = Date.now();
|
||||
try {
|
||||
const idleTimeMs = normalizeIdleTimeMs(await desktopIdleApi.getSystemIdleTimeMs());
|
||||
if (idleTimeMs === null) {
|
||||
this.lastSystemIdleFailureAt = Date.now();
|
||||
this.updateIdleStateFromLocalActivity();
|
||||
return;
|
||||
} else {
|
||||
this.lastSystemActivityTime = Math.max(this.lastSystemActivityTime, Date.now() - idleTimeMs);
|
||||
}
|
||||
if (activityVersion !== this.activityVersion && this.lastActivityTime >= requestedAt) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
this.lastActivityTime = Math.max(0, now - idleTimeMs);
|
||||
this.applyIdleState(idleTimeMs >= IDLE_DURATION_MS);
|
||||
} catch {
|
||||
this.lastSystemIdleFailureAt = Date.now();
|
||||
this.updateIdleStateFromLocalActivity();
|
||||
} finally {
|
||||
this.systemIdleCheckInFlight = false;
|
||||
}
|
||||
this.updateIdleStateFromLocalActivity();
|
||||
}
|
||||
|
||||
private applyIdleState(idle: boolean): void {
|
||||
|
||||
@@ -24,26 +24,30 @@ class UserProfile {
|
||||
}
|
||||
|
||||
handleGatewayReady(): void {
|
||||
Object.values(this.profileTimeouts).forEach(clearTimeout);
|
||||
this.profiles = {};
|
||||
this.profileTimeouts = {};
|
||||
this.clearAllProfiles();
|
||||
}
|
||||
|
||||
handleProfileInvalidate(userId: string, guildId?: string): void {
|
||||
const targetGuildId = guildId ?? ME;
|
||||
this.clearProfileTimeout(userId, targetGuildId);
|
||||
const userProfiles = this.profiles[userId];
|
||||
if (!userProfiles) return;
|
||||
const {[targetGuildId]: _, ...remainingGuildProfiles} = userProfiles;
|
||||
if (Object.keys(remainingGuildProfiles).length === 0) {
|
||||
const {[userId]: __, ...remainingProfiles} = this.profiles;
|
||||
this.profiles = remainingProfiles;
|
||||
} else {
|
||||
this.profiles = {
|
||||
...this.profiles,
|
||||
[userId]: remainingGuildProfiles,
|
||||
};
|
||||
}
|
||||
this.removeProfile(userId, targetGuildId);
|
||||
}
|
||||
|
||||
handleGuildMemberAdd(userId: string): void {
|
||||
this.clearUserProfiles(userId);
|
||||
}
|
||||
|
||||
handleGuildMemberRemove(userId: string): void {
|
||||
this.clearUserProfiles(userId);
|
||||
}
|
||||
|
||||
handleGuildCreate(): void {
|
||||
this.clearAllProfiles();
|
||||
}
|
||||
|
||||
handleGuildDelete(unavailable?: boolean): void {
|
||||
if (unavailable) return;
|
||||
this.clearAllProfiles();
|
||||
}
|
||||
|
||||
handleProfileCreate(profile: Profile): void {
|
||||
@@ -82,6 +86,37 @@ class UserProfile {
|
||||
this.profileTimeouts = updatedTimeouts;
|
||||
}
|
||||
|
||||
private clearAllProfiles(): void {
|
||||
Object.values(this.profileTimeouts).forEach(clearTimeout);
|
||||
this.profiles = {};
|
||||
this.profileTimeouts = {};
|
||||
}
|
||||
|
||||
private clearUserProfiles(userId: string): void {
|
||||
const userProfiles = this.profiles[userId];
|
||||
if (!userProfiles) return;
|
||||
for (const guildId of Object.keys(userProfiles)) {
|
||||
this.clearProfileTimeout(userId, guildId);
|
||||
}
|
||||
const {[userId]: _, ...remainingProfiles} = this.profiles;
|
||||
this.profiles = remainingProfiles;
|
||||
}
|
||||
|
||||
private removeProfile(userId: string, guildId: string): void {
|
||||
const userProfiles = this.profiles[userId];
|
||||
if (!userProfiles) return;
|
||||
const {[guildId]: _, ...remainingGuildProfiles} = userProfiles;
|
||||
if (Object.keys(remainingGuildProfiles).length === 0) {
|
||||
const {[userId]: __, ...remainingProfiles} = this.profiles;
|
||||
this.profiles = remainingProfiles;
|
||||
} else {
|
||||
this.profiles = {
|
||||
...this.profiles,
|
||||
[userId]: remainingGuildProfiles,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private createTimeoutKey(userId: string, guildId: string): string {
|
||||
return `${userId}:${guildId}`;
|
||||
}
|
||||
@@ -101,23 +136,8 @@ class UserProfile {
|
||||
this.clearProfileTimeout(userId, guildId);
|
||||
const timeout = setTimeout(() => {
|
||||
runInAction(() => {
|
||||
const userProfiles = this.profiles[userId];
|
||||
if (!userProfiles) {
|
||||
const {[timeoutKey]: _, ...remainingTimeouts} = this.profileTimeouts;
|
||||
this.profileTimeouts = remainingTimeouts;
|
||||
return;
|
||||
}
|
||||
const {[guildId]: _, ...remainingGuildProfiles} = userProfiles;
|
||||
if (Object.keys(remainingGuildProfiles).length === 0) {
|
||||
const {[userId]: __, ...remainingProfiles} = this.profiles;
|
||||
this.profiles = remainingProfiles;
|
||||
} else {
|
||||
this.profiles = {
|
||||
...this.profiles,
|
||||
[userId]: remainingGuildProfiles,
|
||||
};
|
||||
}
|
||||
const {[timeoutKey]: ___, ...remainingTimeouts} = this.profileTimeouts;
|
||||
this.removeProfile(userId, guildId);
|
||||
const {[timeoutKey]: _, ...remainingTimeouts} = this.profileTimeouts;
|
||||
this.profileTimeouts = remainingTimeouts;
|
||||
});
|
||||
}, PROFILE_TIMEOUT_MS);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Profile} from '@app/features/user/models/Profile';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: null}}));
|
||||
|
||||
const {default: UserProfile} = await import('@app/features/user/state/UserProfile');
|
||||
|
||||
const GUILD_ID = '1000';
|
||||
const TARGET_ID = '2000';
|
||||
|
||||
function cache(userId: string = TARGET_ID, guildId?: string): void {
|
||||
UserProfile.handleProfileCreate({
|
||||
userId,
|
||||
guildId: guildId ?? null,
|
||||
mutualGuilds: [{id: GUILD_ID, nick: null}],
|
||||
} as unknown as Profile);
|
||||
}
|
||||
|
||||
describe('UserProfile cache invalidation', () => {
|
||||
afterEach(() => {
|
||||
UserProfile.handleGatewayReady();
|
||||
});
|
||||
|
||||
it('drops every cached scope for a member removed from a community', () => {
|
||||
cache();
|
||||
cache(TARGET_ID, GUILD_ID);
|
||||
expect(UserProfile.getProfile(TARGET_ID)?.mutualGuilds).toHaveLength(1);
|
||||
UserProfile.handleGuildMemberRemove(TARGET_ID);
|
||||
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
|
||||
expect(UserProfile.getProfile(TARGET_ID, GUILD_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it('drops every cached scope for a member who joined a community', () => {
|
||||
cache();
|
||||
UserProfile.handleGuildMemberAdd(TARGET_ID);
|
||||
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps other users cached when one member is removed', () => {
|
||||
cache();
|
||||
cache('3000');
|
||||
UserProfile.handleGuildMemberRemove(TARGET_ID);
|
||||
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
|
||||
expect(UserProfile.getProfile('3000')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('drops cached profiles when the viewer leaves a community', () => {
|
||||
cache();
|
||||
UserProfile.handleGuildDelete(false);
|
||||
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps cached profiles when a community only goes unavailable', () => {
|
||||
cache();
|
||||
UserProfile.handleGuildDelete(true);
|
||||
expect(UserProfile.getProfile(TARGET_ID)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('drops cached profiles when the viewer joins a community', () => {
|
||||
cache();
|
||||
UserProfile.handleGuildCreate();
|
||||
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -257,7 +257,7 @@ class UserSettingsState {
|
||||
status: StatusType = StatusTypes.ONLINE;
|
||||
statusResetsAt: string | null = null;
|
||||
statusResetsTo: string | null = null;
|
||||
theme: string = ThemeTypes.SYSTEM;
|
||||
theme: string = ThemeTypes.DARK;
|
||||
timeFormat: number = TimeFormatTypes.AUTO;
|
||||
locale: string = 'en-US';
|
||||
restrictedGuilds: Array<string> = [];
|
||||
|
||||
@@ -43,6 +43,7 @@ export function useWindowEventListeners({preventDocumentScroll}: WindowEventList
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('mousedown', handleImmediateActivity);
|
||||
window.addEventListener('keydown', handleUserActivity);
|
||||
window.addEventListener('input', handleUserActivity);
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('touchstart', handleImmediateActivity);
|
||||
document.addEventListener('touchstart', preventPinchZoom, {passive: false});
|
||||
@@ -58,6 +59,7 @@ export function useWindowEventListeners({preventDocumentScroll}: WindowEventList
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('mousedown', handleImmediateActivity);
|
||||
window.removeEventListener('keydown', handleUserActivity);
|
||||
window.removeEventListener('input', handleUserActivity);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('touchstart', handleImmediateActivity);
|
||||
document.removeEventListener('touchstart', preventPinchZoom);
|
||||
|
||||
@@ -1242,6 +1242,29 @@ console.log('self-hosting guide against deploy/self-hosting');
|
||||
compareKeyLists('secret', shellSecrets, powershellSecrets);
|
||||
compareKeyLists('non-secret value', shellNonSecrets, powershellNonSecrets);
|
||||
|
||||
const shellUpgrade = parseShellKeys('fluxer_upgrade_secret_keys', 'the install.sh upgrade secret list');
|
||||
const powershellUpgrade = parsePowerShellKeys('FluxerUpgradeSecretKeys', 'the install.ps1 upgrade secret list');
|
||||
compareKeyLists('upgrade secret', shellUpgrade, powershellUpgrade);
|
||||
|
||||
for (const [script, upgrade, secrets] of [
|
||||
['install.sh', shellUpgrade, shellSecrets],
|
||||
['install.ps1', powershellUpgrade, powershellSecrets],
|
||||
] as const) {
|
||||
if (upgrade.size === 0) {
|
||||
problems.push(`${script} declares no upgrade secret keys, so an upgrade mints nothing`);
|
||||
}
|
||||
for (const [name, kind] of upgrade) {
|
||||
const asInstalled = secrets.get(name);
|
||||
if (asInstalled == null) {
|
||||
problems.push(`${script} mints ${name} on an upgrade and never writes it on an install`);
|
||||
continue;
|
||||
}
|
||||
if (asInstalled !== kind) {
|
||||
problems.push(`${name} is ${kind} on an upgrade and ${asInstalled} on an install in ${script}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expectedSecretKind = (name: string): string => {
|
||||
if (name === 'FLUXER_VAPID_PUBLIC_KEY') {
|
||||
return 'vapid_public';
|
||||
|
||||
@@ -20,7 +20,7 @@ An OAuth2 protocol failure raised by the [OAuth2 resource](/http-api/oauth2/) an
|
||||
|
||||
## Supplementary members
|
||||
|
||||
The members a failure adds are fixed by its code, so a client reads only the members documented for the code it matched. `errors` reports field violations. `retry_after` states the delay before another attempt is admitted, `global` distinguishes a global rate limit denial from a route one, `required_scope` names a missing OAuth2 scope, and `has_mfa` and `methods` state the [sudo mode](/http-api/users/mfa/#sudo-mode) proofs an account can supply.
|
||||
The error code determines which supplementary members a failure has, and most codes have none. A client reads only the members documented for the code it matched. `errors` is the list of field violations. `retry_after` is the delay before another attempt is admitted, `global` is `true` on a global rate limit denial and `false` on a route one, `required_scope` is the OAuth2 scope the request is missing, and `has_mfa` and `methods` are the [sudo mode](/http-api/users/mfa/#sudo-mode) proofs an account can supply.
|
||||
|
||||
The two IP ban codes have their own members. `GLOBAL_IP_BANNED` and `GLOBAL_IP_TEMPORARILY_BANNED` both have `ip_address` with the normalised client address, `appeal_email` with the address an appeal is sent to, `appeals_supported` which is `true` only for the permanent ban, and `ban_kind` which is `permanent` or `temporary_24h`. `expires_at` is an ISO 8601 timestamp when the ban records an expiry and `null` otherwise, including on every permanent ban.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ title: HTTP API
|
||||
description: Request format, body representations, shared headers, cross-origin policy, and the error objects.
|
||||
---
|
||||
|
||||
The Fluxer HTTP API is the set of routes a client calls to read and change data. Every route is published under `/v1`, and `/v1` is the only version. The base URL comes from `endpoints.api` in the [instance discovery document](/http-api/instance/#get-instance-discovery), which is served unversioned at `/.well-known/fluxer`.
|
||||
The Fluxer HTTP API is the set of routes a client calls to read and change data. Every route is published under `/v1`, and `/v1` is the only version. The base URL comes from the [instance discovery document](/http-api/instance/#get-instance-discovery), which is served unversioned at `/.well-known/fluxer`. A third-party client reads `endpoints.api_public` there, and the first-party web application reads `endpoints.api_client`.
|
||||
|
||||
Every route is also mounted at the root, so a path resolves with or without the prefix. A client MUST use the `/v1` form. [Download stored object](/http-api/downloads/#download-stored-object) is the one exception, and it resolves at the root alone.
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ Each value is an absolute URL supplied by the operator. A value can be a bare or
|
||||
| gift | string | Gift link base URL |
|
||||
| webapp | string | Web application base URL |
|
||||
|
||||
<sup>1</sup> Both fields are the same configured client API endpoint, while `api_public` is the separately configured public API endpoint
|
||||
<sup>1</sup> Both fields are the same configured client API endpoint, which the first-party web application reads. `api_public` is the separately configured public API endpoint, which a bot, a library, or any other third-party client reads. A deployment can point the two at one origin, and the instance Fluxer hosts does not
|
||||
|
||||
<sup>2</sup> The value is the configured Gateway endpoint and its scheme is `ws` or `wss` as the operator configured it
|
||||
|
||||
|
||||
@@ -178,13 +178,14 @@ A matching entry is deleted and [Recent Mention Delete](/gateway/events/#recent-
|
||||
|
||||
Lists the caller's saved message collection. Returns an array of [saved message](#saved-message-object) objects in descending message ID order.
|
||||
|
||||
The response is a bare array with no cursor metadata, so `limit` is the only control and an account holding more than 100 entries cannot page past the first 100. An entry whose channel the caller can no longer reach, or whose message the caller can no longer read, comes back with status `missing_permissions` and a null message. An entry whose message no longer exists is deleted and omitted. The array can hold fewer entries than `limit`.
|
||||
The response is a bare array with no cursor metadata, so `before` and `limit` are the only pagination controls. An entry whose channel the caller can no longer reach, or whose message the caller can no longer read, comes back with status `missing_permissions` and a null message. An entry whose message no longer exists is deleted and omitted. The array can hold fewer entries than `limit`.
|
||||
|
||||
### Query parameters
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| limit?<sup>1</sup> <sup>2</sup> | integer | The maximum number of entries read from the collection (1-100, default 25) |
|
||||
| before?<sup>2</sup> | snowflake | The upper bound on entry message ID, exclusive |
|
||||
|
||||
<sup>1</sup> A value outside the range fails with `VALUE_MUST_BE_INTEGER_IN_RANGE` at the `limit` path
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ A new account starts with these values. Every field not listed starts empty, so
|
||||
|
||||
| Setting | Initial value |
|
||||
| --- | --- |
|
||||
| Theme | The theme chosen at registration, or `system` |
|
||||
| Theme | The theme chosen at registration, or `dark` |
|
||||
| Status | `online` |
|
||||
| Rendering | Animated custom emoji, always-animated stickers, GIF autoplay, embeds and reactions rendered, spoilers on click, inline attachment and embed media |
|
||||
| Incoming calls and group DM additions | Friends only |
|
||||
|
||||
@@ -43,9 +43,11 @@ A client that knows only a Fluxer origin reads endpoint discovery first. `GET /.
|
||||
GET https://example.com/.well-known/fluxer
|
||||
```
|
||||
|
||||
The instance Fluxer hosts answers discovery at `https://fluxer.app/.well-known/fluxer`. That origin is the one thing a client is given. Every base URL below it still comes from the response.
|
||||
|
||||
It returns the [instance discovery object](/http-api/instance/#instance-discovery-object). Every base URL a client uses comes from the [instance endpoints object](/http-api/instance/#instance-endpoints-object) inside it. A client MUST read every base URL from that response, and it MUST NOT derive one from the origin it was given or assume an official Fluxer domain.
|
||||
|
||||
Take `endpoints.api` from that response, then send a credential in the `Authorization` header.
|
||||
Take the base URL for the kind of client being built, then send a credential in the `Authorization` header. A bot, a library, or any other third-party client takes `endpoints.api_public`. `endpoints.api_client` is the endpoint the first-party web application uses, and `endpoints.api` repeats it.
|
||||
|
||||
```text
|
||||
GET https://api.example.com/v1/users/@me
|
||||
|
||||
@@ -17,7 +17,7 @@ By the end you have a Fluxer instance on a hostname you own, with the web app, H
|
||||
| Requirement | Value |
|
||||
| --- | --- |
|
||||
| Host | Any machine that runs Docker with Linux containers. A Linux server, or a Mac or Windows PC with Docker Desktop |
|
||||
| Runtime | Docker Engine 24 or newer with the Compose plugin v2.20.2 or newer. Docker Desktop ships both |
|
||||
| Runtime | Docker Engine 24 or newer, or Podman 5 or newer, with the Compose plugin v2.20.2 or newer |
|
||||
| Processor | Intel, AMD and Apple Silicon all work. Every image ships for `amd64` and `arm64`, and Docker pulls the matching one |
|
||||
| CPU | 2 vCPU minimum, 4 vCPU for a small active community. No service sets a CPU limit, so every container sees all of them |
|
||||
| RAM | 8 GB minimum with the shipped defaults, 16 GB for a small active community. 4 GB needs the [low-memory overrides](/operator/configuration/#resources) |
|
||||
@@ -159,6 +159,7 @@ The run prints one line per phase and ends with the URL to open. It takes severa
|
||||
| `--update` | Upgrade: record, back up, refresh, pull, recreate, verify |
|
||||
| `--rollback` | Put back the images and stack files of the newest record |
|
||||
| `--allow-root` | Permit running as root |
|
||||
| `--engine <command>` | Container engine to drive. Default `docker`, or `podman` when `docker` is absent |
|
||||
|
||||
Run `--dry-run` first to see what a set of flags does. The PowerShell script takes the same flags as `-Domain`, `-Email` and so on, and has no `-AllowRoot`. [Upgrading](/operator/upgrading/#run-the-upgrade) has the flags that only `--update` and `--rollback` read, and the exit codes.
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ param(
|
||||
[string]$Domain = '',
|
||||
[string]$Email = '',
|
||||
[string]$Dir = '',
|
||||
[string]$Engine = '',
|
||||
[string]$Ref = '',
|
||||
[string]$ImageTag = 'v1',
|
||||
[string]$Tls = 'bundled',
|
||||
@@ -65,6 +66,7 @@ $FluxerRawBase = 'https://raw.githubusercontent.com/fluxerapp/fluxer'
|
||||
$FluxerStackPath = 'deploy/self-hosting'
|
||||
$FluxerHealthPath = '/_health'
|
||||
$FluxerInitService = 'seaweedfs-init'
|
||||
$FluxerMinimumPodmanVersion = '5.0.0'
|
||||
$FluxerMinimumEngineVersion = '24.0.0'
|
||||
$FluxerMinimumComposeVersion = '2.20.2'
|
||||
$FluxerReadyTimeoutSeconds = 600
|
||||
@@ -194,6 +196,8 @@ function Show-FluxerUsage {
|
||||
Write-FluxerLine 'Options:'
|
||||
Write-FluxerLine ' -Domain <host> Hostname the instance answers on. Prompted when absent.'
|
||||
Write-FluxerLine ' -Email <address> Address written as FLUXER_VAPID_EMAIL. Prompted when absent.'
|
||||
Write-FluxerLine ' -Engine <command> Container engine to drive. Default: docker, or podman when'
|
||||
Write-FluxerLine ' docker is absent.'
|
||||
Write-FluxerLine ' -Dir <path> Working directory. Default: the fluxer folder in the home'
|
||||
Write-FluxerLine ' directory, or the working directory itself when -Update or'
|
||||
Write-FluxerLine ' -Rollback is given and it holds an instance.'
|
||||
@@ -256,6 +260,9 @@ function Test-FluxerVersionAtLeast([string]$Found, [string]$Minimum) {
|
||||
# is kept rather than discarded. The two streams stay apart because every caller reads Text as
|
||||
# data, and one warning line on stderr would be one more line of JSON, one more image reference or
|
||||
# one more container ID.
|
||||
$script:FluxerEngine = 'docker'
|
||||
$script:FluxerEngineLabel = 'Docker Engine'
|
||||
|
||||
function Invoke-FluxerCapture([string[]]$CommandArgs) {
|
||||
$previous = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
@@ -263,7 +270,7 @@ function Invoke-FluxerCapture([string[]]$CommandArgs) {
|
||||
$err = @()
|
||||
$code = 0
|
||||
try {
|
||||
$output = & docker @CommandArgs 2>&1
|
||||
$output = & $script:FluxerEngine @CommandArgs 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
foreach ($item in @($output)) {
|
||||
if ($item -is [System.Management.Automation.ErrorRecord]) {
|
||||
@@ -283,7 +290,7 @@ function Invoke-FluxerDocker([string[]]$CommandArgs) {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$code = 0
|
||||
try {
|
||||
& docker @CommandArgs
|
||||
& $script:FluxerEngine @CommandArgs
|
||||
$code = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $previous
|
||||
@@ -295,7 +302,7 @@ function Invoke-FluxerDocker([string[]]$CommandArgs) {
|
||||
# it, so the bytes go to the file through the process itself.
|
||||
function Invoke-FluxerDockerToFile([string[]]$CommandArgs, [string]$OutFile, [string]$WorkingDir) {
|
||||
$errorFile = "$OutFile.stderr"
|
||||
$process = Start-Process -FilePath 'docker' -ArgumentList $CommandArgs -RedirectStandardOutput $OutFile -RedirectStandardError $errorFile -WorkingDirectory $WorkingDir -NoNewWindow -Wait -PassThru
|
||||
$process = Start-Process -FilePath $script:FluxerEngine -ArgumentList $CommandArgs -RedirectStandardOutput $OutFile -RedirectStandardError $errorFile -WorkingDirectory $WorkingDir -NoNewWindow -Wait -PassThru
|
||||
$code = $process.ExitCode
|
||||
if (Test-Path -LiteralPath $errorFile) {
|
||||
Remove-Item -LiteralPath $errorFile -Force
|
||||
@@ -330,22 +337,40 @@ function Invoke-FluxerPreflight {
|
||||
if ($PSVersionTable.PSVersion.Major -lt 6) {
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
}
|
||||
if ($null -eq (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
Stop-Fluxer 'docker is not on PATH. Install Docker Desktop with the WSL2 backend.' $FluxerExitPrerequisite
|
||||
if ($Engine.Length -gt 0) {
|
||||
$script:FluxerEngine = $Engine
|
||||
} elseif ($null -ne (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
$script:FluxerEngine = 'docker'
|
||||
} elseif ($null -ne (Get-Command podman -ErrorAction SilentlyContinue)) {
|
||||
$script:FluxerEngine = 'podman'
|
||||
} else {
|
||||
Stop-Fluxer 'Neither docker nor podman is on PATH. Install Docker Desktop with the WSL2 backend, or pass -Engine with the command that drives your containers.' $FluxerExitPrerequisite
|
||||
}
|
||||
if ($null -eq (Get-Command $script:FluxerEngine -ErrorAction SilentlyContinue)) {
|
||||
Stop-Fluxer "$($script:FluxerEngine) is not on PATH. Pass -Engine with the command that drives your containers." $FluxerExitPrerequisite
|
||||
}
|
||||
# Every engine that speaks the Docker CLI reports itself as "<name> version X",
|
||||
# and Podman's 5.x is not Docker's 5.x, so the floor has to belong to whichever
|
||||
# one answered.
|
||||
$reported = Invoke-FluxerCapture @('--version')
|
||||
$minimumEngine = $FluxerMinimumEngineVersion
|
||||
if ($reported.Code -eq 0 -and $reported.Text -match '^\s*podman\s+version') {
|
||||
$script:FluxerEngineLabel = 'Podman'
|
||||
$minimumEngine = $FluxerMinimumPodmanVersion
|
||||
}
|
||||
$engine = Invoke-FluxerCapture @('version', '--format', '{{.Server.Version}}')
|
||||
if ($engine.Code -ne 0) {
|
||||
Stop-Fluxer 'The Docker daemon does not answer. Start Docker Desktop and run this script again.' $FluxerExitPrerequisite
|
||||
Stop-Fluxer "$($script:FluxerEngine) does not answer. Start it and run this script again." $FluxerExitPrerequisite
|
||||
}
|
||||
$compose = Invoke-FluxerCapture @('compose', 'version', '--short')
|
||||
if ($compose.Code -ne 0) {
|
||||
Stop-Fluxer 'The Docker Compose v2 plugin is missing. Docker Desktop ships it.' $FluxerExitPrerequisite
|
||||
}
|
||||
if (-not (Test-FluxerVersionAtLeast $engine.Text $FluxerMinimumEngineVersion)) {
|
||||
Stop-Fluxer "Docker Engine $($engine.Text) is older than $FluxerMinimumEngineVersion. Compose is $($compose.Text)." $FluxerExitPrerequisite
|
||||
if (-not (Test-FluxerVersionAtLeast $engine.Text $minimumEngine)) {
|
||||
Stop-Fluxer "$($script:FluxerEngineLabel) $($engine.Text) is older than $minimumEngine. Compose is $($compose.Text)." $FluxerExitPrerequisite
|
||||
}
|
||||
if (-not (Test-FluxerVersionAtLeast $compose.Text $FluxerMinimumComposeVersion)) {
|
||||
Stop-Fluxer "Docker Compose $($compose.Text) is older than $FluxerMinimumComposeVersion. Engine is $($engine.Text)." $FluxerExitPrerequisite
|
||||
Stop-Fluxer "Compose $($compose.Text) is older than $FluxerMinimumComposeVersion. $($script:FluxerEngineLabel) is $($engine.Text)." $FluxerExitPrerequisite
|
||||
}
|
||||
$osType = Invoke-FluxerCapture @('info', '--format', '{{.OSType}}')
|
||||
if ($osType.Code -ne 0) {
|
||||
@@ -354,7 +379,7 @@ function Invoke-FluxerPreflight {
|
||||
if ($osType.Text -ne 'linux') {
|
||||
Stop-Fluxer "Docker runs $($osType.Text) containers. Switch Docker Desktop to Linux containers." $FluxerExitPrerequisite
|
||||
}
|
||||
Write-FluxerLine "Docker Engine $($engine.Text) and Compose $($compose.Text) are ready."
|
||||
Write-FluxerLine "$($script:FluxerEngineLabel) $($engine.Text) and Compose $($compose.Text) are ready."
|
||||
if (-not (Test-FluxerWsl2)) {
|
||||
Write-FluxerLine 'WSL 2 is not present. Docker Desktop with the WSL2 backend is the supported Windows setup.'
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ export LC_ALL
|
||||
FLUXER_RAW_BASE='https://raw.githubusercontent.com/fluxerapp/fluxer'
|
||||
FLUXER_STACK_PATH='deploy/self-hosting'
|
||||
FLUXER_MIN_ENGINE='24.0.0'
|
||||
# Podman numbers its releases on its own scale, so the Docker Engine floor says
|
||||
# nothing about it. 5.0 is the first release that runs this stack's compose file
|
||||
# as written, healthcheck conditions and all.
|
||||
FLUXER_MIN_PODMAN='5.0.0'
|
||||
FLUXER_MIN_COMPOSE='2.20.2'
|
||||
# Both overlays this script downloads use the !override tag, which Compose learned
|
||||
# in 2.24.4. A stack that loads neither runs on the lower minimum, so the higher
|
||||
@@ -174,6 +178,8 @@ Usage: sh install.sh --domain <host> --email <address> [options]
|
||||
Options:
|
||||
--domain <host> Hostname the instance answers on. Prompted when absent.
|
||||
--email <address> Address the operator reads. Prompted when absent.
|
||||
--engine <command> Container engine to drive. Default docker, or podman
|
||||
when docker is absent.
|
||||
--dir <path> Working directory. Default ~/fluxer, or the working
|
||||
directory itself when --update or --rollback is given
|
||||
and it holds an instance.
|
||||
@@ -184,7 +190,7 @@ Options:
|
||||
--edge-bind <addr:port> Plain HTTP bind under --tls proxy. Default 127.0.0.1:8080.
|
||||
--non-interactive Never prompt. A missing required value is an error.
|
||||
--dry-run Print the plan. Change nothing.
|
||||
--no-start Write everything and skip docker compose up -d.
|
||||
--no-start Write everything and skip $fluxer_engine compose up -d.
|
||||
--update Upgrade: record, back up, refresh, pull, recreate, verify.
|
||||
--rollback Restore the images and stack files of the last record.
|
||||
--backup-dir <path> Where records go. Default <dir>/backups.
|
||||
@@ -247,6 +253,9 @@ trap fluxer_on_signal TERM
|
||||
opt_domain=''
|
||||
opt_email=''
|
||||
opt_dir=''
|
||||
opt_engine=''
|
||||
fluxer_engine='docker'
|
||||
fluxer_engine_label='Docker Engine'
|
||||
opt_ref=''
|
||||
opt_image_tag='v1'
|
||||
opt_tls='bundled'
|
||||
@@ -273,6 +282,11 @@ while [ $# -gt 0 ]; do
|
||||
opt_email=$2
|
||||
shift 2
|
||||
;;
|
||||
--engine)
|
||||
fluxer_take_value '--engine' $# "${2:-}"
|
||||
opt_engine=$2
|
||||
shift 2
|
||||
;;
|
||||
--dir)
|
||||
fluxer_take_value '--dir' $# "${2:-}"
|
||||
opt_dir=$2
|
||||
@@ -467,29 +481,59 @@ fluxer_version_ge() {
|
||||
fluxer_engine_version=''
|
||||
fluxer_compose_version=''
|
||||
|
||||
# Every engine that speaks the Docker CLI reports itself as "<name> version X".
|
||||
# Docker says "Docker version 28.0.0, build ...", Podman says "podman version
|
||||
# 5.8.4", and the podman-docker shim answers as Podman under the name docker.
|
||||
# Reading the name as well as the number is what lets the floor below belong to
|
||||
# the engine actually in use, because Podman's 5.x is not Docker's 5.x.
|
||||
fluxer_resolve_engine() {
|
||||
if [ -n "$opt_engine" ]; then
|
||||
fluxer_engine=$opt_engine
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
fluxer_engine='docker'
|
||||
elif command -v podman >/dev/null 2>&1; then
|
||||
fluxer_engine='podman'
|
||||
else
|
||||
fluxer_fail 2 "Neither docker nor podman is installed. $(fluxer_docker_hint)"
|
||||
fi
|
||||
if ! command -v "$fluxer_engine" >/dev/null 2>&1; then
|
||||
fluxer_fail 2 "$fluxer_engine is not installed. Pass --engine with the command that drives your containers."
|
||||
fi
|
||||
}
|
||||
|
||||
fluxer_preflight() {
|
||||
if [ "$opt_allow_root" -eq 0 ] && [ "$(id -u)" -eq 0 ]; then
|
||||
fluxer_fail 2 'Running as root. Use an account in the docker group, or pass --allow-root.'
|
||||
fi
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
fluxer_fail 2 "Docker is not installed. $(fluxer_docker_hint)"
|
||||
fluxer_resolve_engine
|
||||
if ! $fluxer_engine compose version >/dev/null 2>&1; then
|
||||
fluxer_fail 2 "$fluxer_engine has no compose subcommand. $(fluxer_docker_hint)"
|
||||
fi
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
fluxer_fail 2 "The Docker Compose v2 plugin is missing. $(fluxer_docker_hint)"
|
||||
fi
|
||||
fluxer_engine_version=$(docker --version 2>/dev/null | sed -n 's/^Docker version \([0-9][0-9.]*\).*/\1/p')
|
||||
fluxer_compose_version=$(docker compose version --short 2>/dev/null | sed -n 's/^v\{0,1\}\([0-9][0-9.]*\).*/\1/p')
|
||||
fluxer_engine_report=$($fluxer_engine --version 2>/dev/null)
|
||||
fluxer_engine_kind=$(printf '%s\n' "$fluxer_engine_report" | sed -n 's/^\([A-Za-z][A-Za-z]*\) version .*/\1/p' | tr 'A-Z' 'a-z')
|
||||
fluxer_engine_version=$(printf '%s\n' "$fluxer_engine_report" | sed -n 's/^[A-Za-z][A-Za-z]* version v\{0,1\}\([0-9][0-9.]*\).*/\1/p')
|
||||
fluxer_compose_version=$($fluxer_engine compose version --short 2>/dev/null | sed -n 's/^v\{0,1\}\([0-9][0-9.]*\).*/\1/p')
|
||||
if [ -z "$fluxer_engine_version" ] || [ -z "$fluxer_compose_version" ]; then
|
||||
fluxer_fail 2 'Cannot read the Docker Engine and Compose versions.'
|
||||
fluxer_fail 2 "Cannot read the engine and Compose versions from $fluxer_engine. It answered \"$fluxer_engine_report\" to --version."
|
||||
fi
|
||||
if ! fluxer_version_ge "$fluxer_engine_version" "$FLUXER_MIN_ENGINE"; then
|
||||
fluxer_fail 2 "Docker Engine $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs Engine $FLUXER_MIN_ENGINE or newer."
|
||||
case $fluxer_engine_kind in
|
||||
podman)
|
||||
fluxer_engine_label='Podman'
|
||||
fluxer_min_engine=$FLUXER_MIN_PODMAN
|
||||
;;
|
||||
*)
|
||||
fluxer_engine_label='Docker Engine'
|
||||
fluxer_min_engine=$FLUXER_MIN_ENGINE
|
||||
;;
|
||||
esac
|
||||
if ! fluxer_version_ge "$fluxer_engine_version" "$fluxer_min_engine"; then
|
||||
fluxer_fail 2 "$fluxer_engine_label $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs $fluxer_engine_label $fluxer_min_engine or newer."
|
||||
fi
|
||||
if ! fluxer_version_ge "$fluxer_compose_version" "$FLUXER_MIN_COMPOSE"; then
|
||||
fluxer_fail 2 "Docker Engine $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs Compose $FLUXER_MIN_COMPOSE or newer."
|
||||
fluxer_fail 2 "$fluxer_engine_label $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs Compose $FLUXER_MIN_COMPOSE or newer."
|
||||
fi
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
fluxer_fail 2 'The Docker daemon does not answer. Start Docker and run this again.'
|
||||
if ! $fluxer_engine info >/dev/null 2>&1; then
|
||||
fluxer_fail 2 "$fluxer_engine does not answer. Start it and run this again."
|
||||
fi
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
fluxer_fail 2 "curl is not installed. $(fluxer_host_tool_hint curl)"
|
||||
@@ -497,7 +541,7 @@ fluxer_preflight() {
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
fluxer_fail 2 "openssl is not installed. $(fluxer_host_tool_hint openssl)"
|
||||
fi
|
||||
fluxer_say "Docker Engine $fluxer_engine_version with Compose $fluxer_compose_version."
|
||||
fluxer_say "$fluxer_engine_label $fluxer_engine_version with Compose $fluxer_compose_version."
|
||||
}
|
||||
|
||||
fluxer_valid_domain() {
|
||||
@@ -735,9 +779,9 @@ fluxer_check_volumes() {
|
||||
if [ -z "$fluxer_project" ]; then
|
||||
return 0
|
||||
fi
|
||||
docker volume ls -q --filter "label=com.docker.compose.project=$fluxer_project" > "$fluxer_scratch/volumes" 2>/dev/null || return 0
|
||||
$fluxer_engine volume ls -q --filter "label=com.docker.compose.project=$fluxer_project" > "$fluxer_scratch/volumes" 2>/dev/null || return 0
|
||||
if [ -s "$fluxer_scratch/volumes" ]; then
|
||||
fluxer_fail 3 "Docker already holds volumes for the $fluxer_project project. They hold the secrets of an earlier install, and this .env does not open them. Run install.sh --update in the directory that holds that instance, or remove the volumes with docker volume rm before you install again."
|
||||
fluxer_fail 3 "Docker already holds volumes for the $fluxer_project project. They hold the secrets of an earlier install, and this .env does not open them. Run install.sh --update in the directory that holds that instance, or remove the volumes with $fluxer_engine volume rm before you install again."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -845,9 +889,9 @@ fluxer_write_env() {
|
||||
}
|
||||
|
||||
fluxer_stack_ready() {
|
||||
docker compose ps -aq > "$fluxer_scratch/ids" 2>/dev/null || return 1
|
||||
$fluxer_engine compose ps -aq > "$fluxer_scratch/ids" 2>/dev/null || return 1
|
||||
[ -s "$fluxer_scratch/ids" ] || return 1
|
||||
xargs docker inspect --format "$FLUXER_INSPECT_FORMAT" < "$fluxer_scratch/ids" > "$fluxer_scratch/state" 2>/dev/null || return 1
|
||||
xargs $fluxer_engine inspect --format "$FLUXER_INSPECT_FORMAT" < "$fluxer_scratch/ids" > "$fluxer_scratch/state" 2>/dev/null || return 1
|
||||
fluxer_ready=1
|
||||
fluxer_init_done=0
|
||||
while read -r fluxer_service fluxer_status fluxer_health fluxer_code; do
|
||||
@@ -1078,11 +1122,11 @@ fluxer_require_compose_files() {
|
||||
fluxer_compose_count=$((${fluxer_compose_count:-0} + 1))
|
||||
[ ! -e "$fluxer_path" ] || continue
|
||||
if fluxer_stack_files | grep -qxF "$fluxer_name"; then
|
||||
fluxer_fail 2 "COMPOSE_FILE from $fluxer_compose_from names $fluxer_name and $fluxer_path is not there, so every docker compose command in $opt_dir fails and this run stops before it changes anything. This script downloads $fluxer_name, and an instance set up before it existed does not hold that file yet. Put it in place and run this again:
|
||||
fluxer_fail 2 "COMPOSE_FILE from $fluxer_compose_from names $fluxer_name and $fluxer_path is not there, so every $fluxer_engine compose command in $opt_dir fails and this run stops before it changes anything. This script downloads $fluxer_name, and an instance set up before it existed does not hold that file yet. Put it in place and run this again:
|
||||
curl -fsSL --proto '=https' --tlsv1.2 -o $fluxer_path $FLUXER_RAW_BASE/$opt_ref/$FLUXER_STACK_PATH/$fluxer_name
|
||||
Leave the COMPOSE_FILE line as it is. Without $fluxer_name the edge container binds 80 and 443 and requests its own certificate."
|
||||
fi
|
||||
fluxer_fail 2 "COMPOSE_FILE from $fluxer_compose_from names $fluxer_name and $fluxer_path is not there, so every docker compose command in $opt_dir fails. This script does not download $fluxer_name. Put that file back, or take it out of the COMPOSE_FILE line."
|
||||
fluxer_fail 2 "COMPOSE_FILE from $fluxer_compose_from names $fluxer_name and $fluxer_path is not there, so every $fluxer_engine compose command in $opt_dir fails. This script does not download $fluxer_name. Put that file back, or take it out of the COMPOSE_FILE line."
|
||||
done
|
||||
if [ "$fluxer_compose_count" -gt 1 ] &&
|
||||
! fluxer_version_ge "$fluxer_compose_version" "$FLUXER_MIN_COMPOSE_OVERLAY"; then
|
||||
@@ -1102,7 +1146,7 @@ Leave the COMPOSE_FILE line as it is. Without $fluxer_name the edge container bi
|
||||
# command.
|
||||
fluxer_compose_query() {
|
||||
fluxer_query_status=0
|
||||
docker compose config "$1" > "$fluxer_scratch/compose-out" 2> "$fluxer_scratch/compose-err" || fluxer_query_status=$?
|
||||
$fluxer_engine compose config "$1" > "$fluxer_scratch/compose-out" 2> "$fluxer_scratch/compose-err" || fluxer_query_status=$?
|
||||
return "$fluxer_query_status"
|
||||
}
|
||||
|
||||
@@ -1151,14 +1195,14 @@ fluxer_indent_file() {
|
||||
}
|
||||
|
||||
fluxer_running_image_ids() {
|
||||
if ! docker compose ps -aq > "$fluxer_scratch/containers" 2> "$fluxer_scratch/ps-err"; then
|
||||
fluxer_fail 2 "docker compose ps failed in $opt_dir, so what is running cannot be read and the record would name no image ID at all. Compose printed:
|
||||
if ! $fluxer_engine compose ps -aq > "$fluxer_scratch/containers" 2> "$fluxer_scratch/ps-err"; then
|
||||
fluxer_fail 2 "$fluxer_engine compose ps failed in $opt_dir, so what is running cannot be read and the record would name no image ID at all. Compose printed:
|
||||
$(fluxer_indent_file "$fluxer_scratch/ps-err" ' ')"
|
||||
fi
|
||||
[ -s "$fluxer_scratch/containers" ] || return 0
|
||||
if ! xargs docker inspect --format '{{.Config.Image}} {{.Image}}' \
|
||||
if ! xargs $fluxer_engine inspect --format '{{.Config.Image}} {{.Image}}' \
|
||||
< "$fluxer_scratch/containers" > "$fluxer_scratch/inspected" 2> "$fluxer_scratch/inspect-err"; then
|
||||
fluxer_fail 2 "docker inspect failed in $opt_dir, so the image ID under each reference cannot be read and a rollback would have nothing to go back to. Docker printed:
|
||||
fluxer_fail 2 "$fluxer_engine inspect failed in $opt_dir, so the image ID under each reference cannot be read and a rollback would have nothing to go back to. Docker printed:
|
||||
$(fluxer_indent_file "$fluxer_scratch/inspect-err" ' ')"
|
||||
fi
|
||||
sort -u "$fluxer_scratch/inspected"
|
||||
@@ -1187,11 +1231,11 @@ fluxer_recorded_id_for() {
|
||||
fluxer_record_state() {
|
||||
fluxer_running_image_ids > "$fluxer_scratch/running"
|
||||
if ! fluxer_compose_images > "$fluxer_scratch/refs"; then
|
||||
fluxer_fail 2 "docker compose config --images failed in $opt_dir, so the running version cannot be recorded. Compose printed:
|
||||
fluxer_fail 2 "$fluxer_engine compose config --images failed in $opt_dir, so the running version cannot be recorded. Compose printed:
|
||||
$(fluxer_compose_error ' ')"
|
||||
fi
|
||||
if [ ! -s "$fluxer_scratch/refs" ]; then
|
||||
fluxer_fail 2 "docker compose config --images returned nothing in $opt_dir, so the running version cannot be recorded. The stack files there declare no service with an image."
|
||||
fluxer_fail 2 "$fluxer_engine compose config --images returned nothing in $opt_dir, so the running version cannot be recorded. The stack files there declare no service with an image."
|
||||
fi
|
||||
fluxer_prepare_record
|
||||
printf '%s\n' "$(fluxer_env_value FLUXER_IMAGE_TAG)" > "$fluxer_record/$FLUXER_TAG_FILE"
|
||||
@@ -1225,9 +1269,9 @@ fluxer_save_current_files() {
|
||||
}
|
||||
|
||||
fluxer_postgres_running() {
|
||||
fluxer_pg_id=$(docker compose ps -q postgres 2>/dev/null | head -n 1)
|
||||
fluxer_pg_id=$($fluxer_engine compose ps -q postgres 2>/dev/null | head -n 1)
|
||||
[ -n "$fluxer_pg_id" ] || return 1
|
||||
[ "$(docker inspect --format '{{.State.Status}}' "$fluxer_pg_id" 2>/dev/null)" = 'running' ]
|
||||
[ "$($fluxer_engine inspect --format '{{.State.Status}}' "$fluxer_pg_id" 2>/dev/null)" = 'running' ]
|
||||
}
|
||||
|
||||
# Step 2 of an upgrade, and the part that gates the rest.
|
||||
@@ -1255,13 +1299,13 @@ fluxer_postgres_running() {
|
||||
fluxer_dump_postgres() {
|
||||
if ! fluxer_postgres_running; then
|
||||
fluxer_say 'Postgres is not running. Starting it for the dump.'
|
||||
if ! docker compose up -d --wait postgres; then
|
||||
if ! $fluxer_engine compose up -d --wait postgres; then
|
||||
fluxer_fail 7 "Postgres does not start in $opt_dir, so no dump can be taken."
|
||||
fi
|
||||
fi
|
||||
fluxer_dump_path="$fluxer_record/$FLUXER_DUMP_FILE"
|
||||
fluxer_say 'Dumping the database.'
|
||||
if ! docker compose exec -T postgres pg_dump -U fluxer -d fluxer --format=custom > "$fluxer_dump_path"; then
|
||||
if ! $fluxer_engine compose exec -T postgres pg_dump -U fluxer -d fluxer --format=custom > "$fluxer_dump_path"; then
|
||||
rm -f "$fluxer_dump_path"
|
||||
fluxer_fail 7 'pg_dump failed. The instance is untouched.'
|
||||
fi
|
||||
@@ -1282,7 +1326,7 @@ fluxer_volume_error() {
|
||||
}
|
||||
|
||||
fluxer_volume_size_kb() {
|
||||
docker run --rm -v "$1:/data:ro" "$FLUXER_HELPER_IMAGE" du -sk /data 2> "$fluxer_scratch/volume-err" |
|
||||
$fluxer_engine run --rm -v "$1:/data:ro" "$FLUXER_HELPER_IMAGE" du -sk /data 2> "$fluxer_scratch/volume-err" |
|
||||
awk 'NR==1 {print $1}'
|
||||
}
|
||||
|
||||
@@ -1307,7 +1351,7 @@ fluxer_copy_volumes() {
|
||||
while read -r fluxer_volume; do
|
||||
[ -n "$fluxer_volume" ] || continue
|
||||
fluxer_full="${fluxer_project}_${fluxer_volume}"
|
||||
if ! docker volume inspect "$fluxer_full" >/dev/null 2>&1; then
|
||||
if ! $fluxer_engine volume inspect "$fluxer_full" >/dev/null 2>&1; then
|
||||
fluxer_fail 7 "The volume $fluxer_full does not exist, so the uploads cannot be copied. The stack declares that volume, so this is a project name other than $fluxer_project or a volume that was removed. Pass --no-volume-backup to take the database dump alone when the uploads of this instance live somewhere this script cannot reach."
|
||||
fi
|
||||
fluxer_size=$(fluxer_volume_size_kb "$fluxer_full")
|
||||
@@ -1331,21 +1375,21 @@ $(fluxer_volume_error ' ')" ;;
|
||||
return 0
|
||||
fi
|
||||
fluxer_say 'Stopping the stack for a consistent copy of the uploads.'
|
||||
if ! docker compose stop; then
|
||||
fluxer_fail 7 "docker compose stop failed in $opt_dir."
|
||||
if ! $fluxer_engine compose stop; then
|
||||
fluxer_fail 7 "$fluxer_engine compose stop failed in $opt_dir."
|
||||
fi
|
||||
while read -r fluxer_volume; do
|
||||
[ -n "$fluxer_volume" ] || continue
|
||||
fluxer_full="${fluxer_project}_${fluxer_volume}"
|
||||
fluxer_say "Copying $fluxer_full."
|
||||
if ! docker run --rm -v "$fluxer_full:/data:ro" -v "$fluxer_record:/backup" "$FLUXER_HELPER_IMAGE" tar czf "/backup/$fluxer_volume.tgz" -C /data .; then
|
||||
docker compose up -d --remove-orphans || true
|
||||
if ! $fluxer_engine run --rm -v "$fluxer_full:/data:ro" -v "$fluxer_record:/backup" "$FLUXER_HELPER_IMAGE" tar czf "/backup/$fluxer_volume.tgz" -C /data .; then
|
||||
$fluxer_engine compose up -d --remove-orphans || true
|
||||
fluxer_fail 7 "Copying $fluxer_full failed. The stack is started again on the images it was running."
|
||||
fi
|
||||
done < "$fluxer_scratch/copy-volumes"
|
||||
fluxer_say 'Starting the stack again before the upgrade continues.'
|
||||
if ! docker compose up -d --remove-orphans; then
|
||||
fluxer_fail 7 "docker compose up -d failed in $opt_dir after the copy. Read docker compose logs there."
|
||||
if ! $fluxer_engine compose up -d --remove-orphans; then
|
||||
fluxer_fail 7 "$fluxer_engine compose up -d failed in $opt_dir after the copy. Read $fluxer_engine compose logs there."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1420,7 +1464,7 @@ fluxer_compose_services() {
|
||||
fluxer_restart_mounted() {
|
||||
[ -s "$fluxer_scratch/restart" ] || return 0
|
||||
if ! fluxer_compose_services > "$fluxer_scratch/services"; then
|
||||
fluxer_fail 6 "docker compose config --services failed in $opt_dir, so the services that mount a refreshed file cannot be restarted. Compose printed:
|
||||
fluxer_fail 6 "$fluxer_engine compose config --services failed in $opt_dir, so the services that mount a refreshed file cannot be restarted. Compose printed:
|
||||
$(fluxer_compose_error ' ')"
|
||||
fi
|
||||
while read -r fluxer_file fluxer_service; do
|
||||
@@ -1430,8 +1474,8 @@ $(fluxer_compose_error ' ')"
|
||||
continue
|
||||
fi
|
||||
fluxer_say "Restarting $fluxer_service, because $fluxer_file is mounted into it and up -d does not reload a mounted file."
|
||||
if ! docker compose restart "$fluxer_service"; then
|
||||
fluxer_fail 6 "docker compose restart $fluxer_service failed in $opt_dir."
|
||||
if ! $fluxer_engine compose restart "$fluxer_service"; then
|
||||
fluxer_fail 6 "$fluxer_engine compose restart $fluxer_service failed in $opt_dir."
|
||||
fi
|
||||
done < "$fluxer_scratch/restart"
|
||||
}
|
||||
@@ -1464,7 +1508,7 @@ fluxer_newest_record() {
|
||||
fluxer_verify_stack() {
|
||||
fluxer_say 'Waiting for every service to report ready.'
|
||||
if ! fluxer_wait_ready; then
|
||||
fluxer_fail 6 "The stack is not ready after $FLUXER_READY_TIMEOUT seconds. Read docker compose logs in $opt_dir."
|
||||
fluxer_fail 6 "The stack is not ready after $FLUXER_READY_TIMEOUT seconds. Read $fluxer_engine compose logs in $opt_dir."
|
||||
fi
|
||||
fluxer_domain_value=$(fluxer_env_value FLUXER_DOMAIN)
|
||||
if [ -n "$fluxer_domain_value" ]; then
|
||||
@@ -1499,7 +1543,7 @@ fluxer_plan_update() {
|
||||
fi
|
||||
fluxer_running_image_ids > "$fluxer_scratch/running"
|
||||
if ! fluxer_compose_images > "$fluxer_scratch/refs"; then
|
||||
fluxer_say ' refusal docker compose config --images fails here, and step 1 of the upgrade reads that list'
|
||||
fluxer_say ' refusal $fluxer_engine compose config --images fails here, and step 1 of the upgrade reads that list'
|
||||
fluxer_say ' compose said'
|
||||
fluxer_compose_error ' '
|
||||
if [ -s "$fluxer_scratch/plan-secrets" ]; then
|
||||
@@ -1560,7 +1604,7 @@ fluxer_plan_update() {
|
||||
fluxer_say " restart $fluxer_service, because $fluxer_file changes and a mounted file survives up -d"
|
||||
done < "$fluxer_scratch/restart"
|
||||
fi
|
||||
fluxer_say ' commands docker compose pull, docker compose up -d'
|
||||
fluxer_say ' commands $fluxer_engine compose pull, $fluxer_engine compose up -d'
|
||||
fluxer_plan_footer
|
||||
fluxer_say 'Drop --dry-run to run this.'
|
||||
}
|
||||
@@ -1586,7 +1630,7 @@ fluxer_plan_rollback() {
|
||||
[ -n "$fluxer_ref" ] || continue
|
||||
if [ "$fluxer_id" = '-' ]; then
|
||||
fluxer_say " $fluxer_ref was not recorded with an ID"
|
||||
elif docker image inspect --format '{{.Id}}' "$fluxer_id" >/dev/null 2>&1; then
|
||||
elif $fluxer_engine image inspect --format '{{.Id}}' "$fluxer_id" >/dev/null 2>&1; then
|
||||
fluxer_say " $fluxer_ref back to $fluxer_id"
|
||||
else
|
||||
fluxer_say " $fluxer_ref is gone from this host, so $fluxer_id cannot come back"
|
||||
@@ -1616,8 +1660,8 @@ fluxer_run_update() {
|
||||
# By hand:
|
||||
# docker compose pull
|
||||
fluxer_say 'Pulling images.'
|
||||
if ! docker compose pull; then
|
||||
fluxer_fail 4 "docker compose pull failed in $opt_dir. The stack files are refreshed and the instance still runs the old images."
|
||||
if ! $fluxer_engine compose pull; then
|
||||
fluxer_fail 4 "$fluxer_engine compose pull failed in $opt_dir. The stack files are refreshed and the instance still runs the old images."
|
||||
fi
|
||||
# Recreates the containers whose image or configuration changed and leaves
|
||||
# the rest running.
|
||||
@@ -1639,8 +1683,8 @@ fluxer_run_update() {
|
||||
# Gateway, so the hostname returns errors for a minute or two after this
|
||||
# call. The api healthcheck allows 90 seconds before it counts a failure.
|
||||
fluxer_say 'Recreating the stack.'
|
||||
if ! docker compose up -d --remove-orphans; then
|
||||
fluxer_fail 6 "docker compose up -d failed in $opt_dir. Read docker compose logs there."
|
||||
if ! $fluxer_engine compose up -d --remove-orphans; then
|
||||
fluxer_fail 6 "$fluxer_engine compose up -d failed in $opt_dir. Read $fluxer_engine compose logs there."
|
||||
fi
|
||||
fluxer_restart_mounted
|
||||
fluxer_verify_stack
|
||||
@@ -1721,18 +1765,18 @@ fluxer_run_rollback() {
|
||||
while read -r fluxer_ref fluxer_id; do
|
||||
[ -n "$fluxer_ref" ] || continue
|
||||
[ "$fluxer_id" != '-' ] || continue
|
||||
if ! docker image inspect --format '{{.Id}}' "$fluxer_id" >/dev/null 2>&1; then
|
||||
if ! $fluxer_engine image inspect --format '{{.Id}}' "$fluxer_id" >/dev/null 2>&1; then
|
||||
fluxer_say "$fluxer_ref is gone from this host, so it keeps the image it has now."
|
||||
continue
|
||||
fi
|
||||
if ! docker image tag "$fluxer_id" "$fluxer_ref"; then
|
||||
if ! $fluxer_engine image tag "$fluxer_id" "$fluxer_ref"; then
|
||||
fluxer_fail 3 "Cannot put $fluxer_id back on $fluxer_ref."
|
||||
fi
|
||||
fluxer_moved=1
|
||||
done < "$fluxer_rollback_dir/$FLUXER_IMAGES_FILE"
|
||||
fi
|
||||
if [ "$fluxer_moved" -eq 0 ]; then
|
||||
fluxer_fail 2 "Nothing in $fluxer_rollback_dir can be put back. The recorded tag is the one in .env and every recorded image has been removed from this host, which a docker image prune does."
|
||||
fluxer_fail 2 "Nothing in $fluxer_rollback_dir can be put back. The recorded tag is the one in .env and every recorded image has been removed from this host, which a $fluxer_engine image prune does."
|
||||
fi
|
||||
|
||||
while read -r fluxer_file; do
|
||||
@@ -1746,8 +1790,8 @@ fluxer_run_rollback() {
|
||||
fluxer_say "Stack files in $opt_dir are the ones the record holds."
|
||||
|
||||
fluxer_say 'Recreating the stack.'
|
||||
if ! docker compose up -d --remove-orphans; then
|
||||
fluxer_fail 6 "docker compose up -d failed in $opt_dir. Read docker compose logs there."
|
||||
if ! $fluxer_engine compose up -d --remove-orphans; then
|
||||
fluxer_fail 6 "$fluxer_engine compose up -d failed in $opt_dir. Read $fluxer_engine compose logs there."
|
||||
fi
|
||||
# The mounted file came back from the record, so its service restarts.
|
||||
# Comparing it first would save one restart and cost the reader a reason.
|
||||
@@ -1820,19 +1864,19 @@ fluxer_write_env
|
||||
fluxer_say "Wrote $opt_dir/.env, readable by you alone."
|
||||
|
||||
if [ "$opt_no_start" -eq 1 ]; then
|
||||
fluxer_say "Start the instance with docker compose up -d in $opt_dir."
|
||||
fluxer_say "Start the instance with $fluxer_engine compose up -d in $opt_dir."
|
||||
fluxer_say 'Open it and create the first admin account. Finish the setup wizard in the same sitting.'
|
||||
fluxer_say "Secrets live in $opt_dir/.env. Back that file up."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fluxer_say 'Starting the stack.'
|
||||
if ! docker compose up -d; then
|
||||
fluxer_fail 6 "docker compose up -d failed in $opt_dir. Read docker compose logs there."
|
||||
if ! $fluxer_engine compose up -d; then
|
||||
fluxer_fail 6 "$fluxer_engine compose up -d failed in $opt_dir. Read $fluxer_engine compose logs there."
|
||||
fi
|
||||
fluxer_say 'Waiting for every service to report ready. This takes several minutes on the first start, which pulls eighteen images.'
|
||||
if ! fluxer_wait_ready; then
|
||||
fluxer_fail 6 "The stack is not ready after $FLUXER_READY_TIMEOUT seconds. Read docker compose logs in $opt_dir."
|
||||
fluxer_fail 6 "The stack is not ready after $FLUXER_READY_TIMEOUT seconds. Read $fluxer_engine compose logs in $opt_dir."
|
||||
fi
|
||||
fluxer_probe "$opt_domain"
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export const MAX_PRIVATE_CHANNELS_PER_USER = 250;
|
||||
export const MAX_GROUP_DMS_PER_USER = 150;
|
||||
export const MAX_BOOKMARKS_PREMIUM = 300;
|
||||
export const MAX_BOOKMARKS_NON_PREMIUM = 50;
|
||||
export const SAVED_MESSAGES_PAGE_SIZE = 50;
|
||||
export const MAX_FAVORITE_MEMES_PREMIUM = 500;
|
||||
export const MAX_FAVORITE_MEMES_NON_PREMIUM = 50;
|
||||
export const MAX_FAVORITE_MEME_TAGS = 10;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::ast::{AlertType, ListItem, Node, ParserFlags, TableAlignment};
|
||||
use crate::constants::{MAX_AST_NODES, MAX_LINE_LENGTH};
|
||||
use crate::constants::{CODE_FENCE_LENGTH, MAX_AST_NODES, MAX_LINE_LENGTH};
|
||||
use crate::links::{has_open_inline_code, has_valid_code_fence_language};
|
||||
use crate::normalize::{normalize_nodes, replace_trailing_whitespace_with_newline};
|
||||
use crate::parser::{MarkdownParser, ParseError, RuntimeState};
|
||||
@@ -157,7 +157,7 @@ pub(crate) fn parse_block(state: &mut RuntimeState<'_>) -> Result<BlockParseResu
|
||||
}
|
||||
|
||||
if ParserFlags::has(state.parser.flags(), ParserFlags::ALLOW_CODE_BLOCKS) {
|
||||
if let Some(fence_pos) = line.find("```") {
|
||||
if let Some(fence_pos) = find_unescaped_fence(&line) {
|
||||
let starts_with_fence =
|
||||
starts_with(trimmed, "```") && fence_pos == line.len() - trimmed.len();
|
||||
if starts_with_fence {
|
||||
@@ -459,7 +459,7 @@ fn parse_code_block(lines: &[Line], current: usize) -> Result<Option<CodeBlockRe
|
||||
while fence_length < trimmed_line.len() && byte_at(trimmed_line, fence_length) == b'`' {
|
||||
fence_length += 1;
|
||||
}
|
||||
if fence_length < 3 {
|
||||
if fence_length < CODE_FENCE_LENGTH {
|
||||
return Ok(None);
|
||||
}
|
||||
let language_part = &trimmed_line[fence_length..];
|
||||
@@ -1197,7 +1197,7 @@ pub(crate) fn opens_code_block_midline(lines: &[Line], index: usize, flags: u32)
|
||||
return false;
|
||||
}
|
||||
let line = &lines[index].text;
|
||||
let Some(fence_pos) = line.find("```") else {
|
||||
let Some(fence_pos) = find_unescaped_fence(line) else {
|
||||
return false;
|
||||
};
|
||||
let trimmed = trim_start(line);
|
||||
@@ -1267,6 +1267,28 @@ fn alert_type(label: &str) -> Option<AlertType> {
|
||||
}
|
||||
}
|
||||
|
||||
fn find_unescaped_fence(line: &str) -> Option<usize> {
|
||||
let mut search = 0usize;
|
||||
while let Some(offset) = line[search..].find("```") {
|
||||
let fence_pos = search + offset;
|
||||
if !is_escaped_fence(line, fence_pos) {
|
||||
return Some(fence_pos);
|
||||
}
|
||||
search = fence_pos + 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_escaped_fence(line: &str, fence_pos: usize) -> bool {
|
||||
let mut backslashes = 0usize;
|
||||
let mut index = fence_pos;
|
||||
while index > 0 && byte_at(line, index - 1) == b'\\' {
|
||||
backslashes += 1;
|
||||
index -= 1;
|
||||
}
|
||||
backslashes % 2 == 1
|
||||
}
|
||||
|
||||
fn slice_lines_from_fence(lines: &[Line], current: usize, fence_pos: usize) -> Vec<Line> {
|
||||
let mut out = Vec::with_capacity(lines.len() - current);
|
||||
out.push(Line {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub const CODE_FENCE_LENGTH: usize = 3;
|
||||
pub const MAX_AST_NODES: usize = 10_000;
|
||||
pub const MAX_INLINE_DEPTH: usize = 10;
|
||||
pub const MAX_LINES: usize = 10_000;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::ast::{EmojiKind, Node, ParserFlags, ParserResult};
|
||||
use crate::constants::{MAX_INLINE_DEPTH, MAX_LINE_LENGTH};
|
||||
use crate::constants::{CODE_FENCE_LENGTH, MAX_INLINE_DEPTH, MAX_LINE_LENGTH};
|
||||
use crate::links;
|
||||
use crate::normalize::{
|
||||
combine_adjacent_text, compact_empty_text_nodes, flatten_top_level_formatting,
|
||||
@@ -125,6 +125,14 @@ fn parse_inline_with_context(
|
||||
position += 1 + emoji.len;
|
||||
continue;
|
||||
}
|
||||
if next == b'`' {
|
||||
let run = backtick_run_length(text, position + 1);
|
||||
if run >= CODE_FENCE_LENGTH {
|
||||
accumulated.extend(std::iter::repeat_n(b'`', run));
|
||||
position += 1 + run;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if is_escapable_character(next)
|
||||
|| is_ordered_list_marker_dot_escape(text, position)
|
||||
|| is_word_dot_escape(text, position)
|
||||
@@ -349,6 +357,14 @@ fn regional_indicator_letter(ch: char) -> Option<char> {
|
||||
char::from_u32(u32::from(b'a') + codepoint - 0x1f1e6)
|
||||
}
|
||||
|
||||
fn backtick_run_length(text: &str, start: usize) -> usize {
|
||||
let mut run = 0usize;
|
||||
while start + run < text.len() && byte_at(text, start + run) == b'`' {
|
||||
run += 1;
|
||||
}
|
||||
run
|
||||
}
|
||||
|
||||
fn is_ordered_list_marker_dot_escape(text: &str, backslash_position: usize) -> bool {
|
||||
if backslash_position + 2 >= text.len()
|
||||
|| byte_at(text, backslash_position + 1) != b'.'
|
||||
|
||||
@@ -202,3 +202,46 @@ fn unterminated_midline_fence_after_a_line_stays_text() {
|
||||
json!([{"type": "Text", "content": "hello\nfoo```bar with no closing fence"}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_fence_stays_text_on_one_line() {
|
||||
assert_eq!(
|
||||
parse("\\```hello```"),
|
||||
json!([{"type": "Text", "content": "```hello```"}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_fence_stays_text_across_lines() {
|
||||
assert_eq!(
|
||||
parse("\\```rust\nfn main() {}\n```"),
|
||||
json!([{"type": "Text", "content": "```rust\nfn main() {}\n```"}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_midline_fence_stays_text() {
|
||||
assert_eq!(
|
||||
parse("label\\```rust\nfn main() {}\n```"),
|
||||
json!([{"type": "Text", "content": "label```rust\nfn main() {}\n```"}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_backslash_before_fence_still_opens_a_code_block() {
|
||||
assert_eq!(
|
||||
parse("\\\\```hello```"),
|
||||
json!([
|
||||
{"type": "Text", "content": "\\"},
|
||||
{"type": "CodeBlock", "content": "hello"}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longer_escaped_fence_stays_text() {
|
||||
assert_eq!(
|
||||
parse("\\````hello````"),
|
||||
json!([{"type": "Text", "content": "````hello````"}])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -590,6 +590,7 @@ export const UserSavedMessagesQueryRequest = z.object({
|
||||
limit: createQueryIntegerType({minValue: 1, maxValue: 100, defaultValue: 25}).describe(
|
||||
'Maximum number of saved messages to return (1-100, default 25)',
|
||||
),
|
||||
before: SnowflakeType.optional().describe('Get saved messages before this message ID'),
|
||||
});
|
||||
|
||||
export type UserSavedMessagesQueryRequest = z.infer<typeof UserSavedMessagesQueryRequest>;
|
||||
|
||||
Reference in New Issue
Block a user