Pinned messages UI fixes

This commit is contained in:
Jamie
2026-01-13 12:01:07 -08:00
committed by GitHub
parent a27a87a934
commit 560224f516
14 changed files with 133 additions and 187 deletions

View File

@@ -1874,83 +1874,19 @@ $message-padding-horizontal: 12px;
display: inline-block; display: inline-block;
margin-inline-start: 6px; margin-inline-start: 6px;
margin-bottom: 2px; margin-bottom: 2px;
& {
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/message_timer/messagetimer-60.svg',
variables.$color-white-alpha-80
);
}
}
& {
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/message_timer/messagetimer-60.svg',
variables.$color-white-alpha-80
);
}
}
} }
$timer-icons: $timer-icons:
'55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', '00'; '60', '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', '00';
@each $timer-icon in $timer-icons { @each $timer-icon in $timer-icons {
.module-expire-timer--#{$timer-icon} { .module-expire-timer--#{$timer-icon} {
& {
@include mixins.dark-theme {
@include mixins.color-svg( @include mixins.color-svg(
'../images/icons/v3/message_timer/messagetimer-#{$timer-icon}.svg', '../images/icons/v3/message_timer/messagetimer-#{$timer-icon}.svg',
variables.$color-white-alpha-80 currentColor
); );
} }
} }
& {
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/message_timer/messagetimer-#{$timer-icon}.svg',
variables.$color-white-alpha-80
);
}
}
}
}
.module-expire-timer--incoming {
background-color: variables.$color-white-alpha-80;
@include mixins.light-theme {
background-color: variables.$color-gray-60;
}
@include mixins.dark-theme {
background-color: variables.$color-gray-25;
}
}
.module-expire-timer--with-sticker {
@include mixins.light-theme {
background-color: variables.$color-gray-60;
}
}
// When status indicators are overlaid on top of an image, they use different colors
.module-expire-timer--with-image-no-caption {
@include mixins.light-theme {
background-color: variables.$color-white;
}
@include mixins.dark-theme {
background-color: variables.$color-gray-02;
}
}
.module-expire-timer--outline-only-bubble {
@include mixins.light-theme {
background-color: variables.$color-gray-60;
}
@include mixins.dark-theme {
background-color: variables.$color-gray-25;
}
}
.module-about { .module-about {
&__container { &__container {

View File

@@ -11,12 +11,9 @@ export default {
} satisfies Meta<Props>; } satisfies Meta<Props>;
const createProps = (overrideProps: Partial<Props> = {}): Props => ({ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
direction: overrideProps.direction || 'outgoing',
expirationLength: overrideProps.expirationLength || 30 * 1000, expirationLength: overrideProps.expirationLength || 30 * 1000,
expirationTimestamp: expirationTimestamp:
overrideProps.expirationTimestamp || Date.now() + 30 * 1000, overrideProps.expirationTimestamp || Date.now() + 30 * 1000,
withImageNoCaption: overrideProps.withImageNoCaption || false,
withSticker: overrideProps.withSticker || false,
}); });
export const _30Seconds = (): React.JSX.Element => { export const _30Seconds = (): React.JSX.Element => {
@@ -51,38 +48,6 @@ export function Expired(): React.JSX.Element {
return <ExpireTimer {...props} />; return <ExpireTimer {...props} />;
} }
export function Sticker(): React.JSX.Element {
const props = createProps({
withSticker: true,
});
return <ExpireTimer {...props} />;
}
export function ImageNoCaption(): React.JSX.Element {
const props = createProps({
withImageNoCaption: true,
});
return (
<div style={{ backgroundColor: 'darkgreen' }}>
<ExpireTimer {...props} />
</div>
);
}
export function Incoming(): React.JSX.Element {
const props = createProps({
direction: 'incoming',
});
return (
<div style={{ backgroundColor: 'darkgreen' }}>
<ExpireTimer {...props} />
</div>
);
}
export function ExpirationTooFarOut(): React.JSX.Element { export function ExpirationTooFarOut(): React.JSX.Element {
const props = createProps({ const props = createProps({
expirationTimestamp: Date.now() + 150 * 1000, expirationTimestamp: Date.now() + 150 * 1000,

View File

@@ -6,22 +6,14 @@ import classNames from 'classnames';
import { getIncrement, getTimerBucket } from '../../util/timer.std.js'; import { getIncrement, getTimerBucket } from '../../util/timer.std.js';
export type Props = { export type Props = Readonly<{
direction?: 'incoming' | 'outgoing';
expirationLength: number; expirationLength: number;
expirationTimestamp?: number; expirationTimestamp?: number;
isOutlineOnlyBubble?: boolean; }>;
withImageNoCaption?: boolean;
withSticker?: boolean;
};
export function ExpireTimer({ export function ExpireTimer({
direction,
expirationLength, expirationLength,
expirationTimestamp, expirationTimestamp,
isOutlineOnlyBubble,
withImageNoCaption,
withSticker,
}: Props): React.JSX.Element { }: Props): React.JSX.Element {
const [, forceUpdate] = useReducer(() => ({}), {}); const [, forceUpdate] = useReducer(() => ({}), {});
@@ -40,13 +32,7 @@ export function ExpireTimer({
<div <div
className={classNames( className={classNames(
'module-expire-timer', 'module-expire-timer',
`module-expire-timer--${bucket}`, `module-expire-timer--${bucket}`
direction ? `module-expire-timer--${direction}` : null,
isOutlineOnlyBubble ? 'module-expire-timer--outline-only-bubble' : null,
direction && withImageNoCaption
? 'module-expire-timer--with-image-no-caption'
: null,
withSticker ? 'module-expire-timer--with-sticker' : null
)} )}
/> />
); );

View File

@@ -220,12 +220,8 @@ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
) : null} ) : null}
{expirationLength ? ( {expirationLength ? (
<ExpireTimer <ExpireTimer
direction={metadataDirection}
isOutlineOnlyBubble={isOutlineOnlyBubble}
expirationLength={expirationLength} expirationLength={expirationLength}
expirationTimestamp={expirationTimestamp} expirationTimestamp={expirationTimestamp}
withImageNoCaption={withImageNoCaption}
withSticker={isSticker}
/> />
) : null} ) : null}
{textPending ? ( {textPending ? (

View File

@@ -77,7 +77,7 @@ const defaultMessageProps: TimelineMessagesProps = {
canEditMessage: true, canEditMessage: true,
canEndPoll: false, canEndPoll: false,
canForward: true, canForward: true,
canPinMessages: true, canPinMessage: true,
canReact: true, canReact: true,
canReply: true, canReply: true,
canRetry: true, canRetry: true,

View File

@@ -47,7 +47,7 @@ function mockMessageTimelineItem(
canEditMessage: true, canEditMessage: true,
canEndPoll: false, canEndPoll: false,
canForward: true, canForward: true,
canPinMessages: true, canPinMessage: true,
canReact: true, canReact: true,
canReply: true, canReply: true,
canRetry: true, canRetry: true,

View File

@@ -239,7 +239,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
canCopy: true, canCopy: true,
canEditMessage: true, canEditMessage: true,
canEndPoll: overrideProps.direction === 'outgoing', canEndPoll: overrideProps.direction === 'outgoing',
canPinMessages: overrideProps.canPinMessages ?? true, canPinMessage: overrideProps.canPinMessage ?? true,
canReact: true, canReact: true,
canReply: true, canReply: true,
canDownload: true, canDownload: true,

View File

@@ -54,7 +54,7 @@ export type PropsData = {
canRetryDeleteForEveryone: boolean; canRetryDeleteForEveryone: boolean;
canReact: boolean; canReact: boolean;
canReply: boolean; canReply: boolean;
canPinMessages: boolean; canPinMessage: boolean;
hasMaxPinnedMessages: boolean; hasMaxPinnedMessages: boolean;
selectedReaction?: string; selectedReaction?: string;
isTargeted?: boolean; isTargeted?: boolean;
@@ -114,7 +114,7 @@ export function TimelineMessage(props: Props): React.JSX.Element {
canReply, canReply,
canRetry, canRetry,
canRetryDeleteForEveryone, canRetryDeleteForEveryone,
canPinMessages, canPinMessage,
containerElementRef, containerElementRef,
containerWidthBreakpoint, containerWidthBreakpoint,
conversationId, conversationId,
@@ -368,11 +368,9 @@ export function TimelineMessage(props: Props): React.JSX.Element {
}); });
}} }}
onPinMessage={ onPinMessage={
canPinMessages && !isPinned ? handleOpenPinMessageDialog : null canPinMessage && !isPinned ? handleOpenPinMessageDialog : null
}
onUnpinMessage={
canPinMessages && isPinned ? handleUnpinMessage : null
} }
onUnpinMessage={canPinMessage && isPinned ? handleUnpinMessage : null}
onMoreInfo={() => onMoreInfo={() =>
pushPanelForConversation({ pushPanelForConversation({
type: PanelType.MessageDetails, type: PanelType.MessageDetails,
@@ -388,7 +386,7 @@ export function TimelineMessage(props: Props): React.JSX.Element {
canCopy, canCopy,
canEditMessage, canEditMessage,
canForward, canForward,
canPinMessages, canPinMessage,
canRetry, canRetry,
canSelect, canSelect,
canEndPoll, canEndPoll,

View File

@@ -182,10 +182,28 @@ export function Variants(): React.JSX.Element {
title="Poll" title="Poll"
message={{ poll: { question: `${SHORT_TEXT}?` } }} message={{ poll: { question: `${SHORT_TEXT}?` } }}
/> />
<Variant title="Sticker" message={{ sticker: true }} /> <Variant
<Variant title="Contact" message={{ contact: { name: 'Tyler' } }} /> title="Sticker"
message={{
sticker: true,
attachment: { type: 'image', url: IMAGE_URL },
}}
/>
<Variant
title="Contact"
message={{
contact: { name: 'Tyler' },
attachment: { type: 'file', name: '' },
}}
/>
<Variant title="Payment" message={{ payment: true }} /> <Variant title="Payment" message={{ payment: true }} />
<Variant title="View-Once Media" message={{ viewOnceMedia: true }} /> <Variant
title="View-Once Media"
message={{
viewOnceMedia: true,
attachment: { type: 'image', url: IMAGE_URL },
}}
/>
</Stack> </Stack>
); );
} }

View File

@@ -191,7 +191,7 @@ function TabsList(props: {
return ( return (
<AriaClickable.SubWidget> <AriaClickable.SubWidget>
<Tabs.List className={tw('flex h-full flex-col')}> <Tabs.List className={tw('flex h-full flex-col')}>
{props.pins.toReversed().map((pin, pinIndex) => { {props.pins.map((pin, pinIndex) => {
return ( return (
<TabTrigger <TabTrigger
key={pin.id} key={pin.id}
@@ -395,7 +395,39 @@ function getMessagePreview(i18n: LocalizerType, message: PinMessage): Preview {
text ??= <MessageTextPreview i18n={i18n} text={message.text} />; text ??= <MessageTextPreview i18n={i18n} text={message.text} />;
} }
// 3. And everything else... // 3. Check specific types of messages (before checking attachments)
if (message.payment) {
text ??= i18n('icu:PinnedMessagesBar__MessagePreview__Text--Payment');
icon ??= {
symbol: 'creditcard',
label: i18n(
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Payment'
),
};
} else if (message.poll != null) {
text ??= <UserText text={message.poll.question} />;
icon ??= {
symbol: 'poll',
label: i18n('icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Poll'),
};
} else if (message.sticker) {
text ??= i18n('icu:PinnedMessagesBar__MessagePreview__Text--Sticker');
icon ??= {
symbol: 'sticker',
label: i18n(
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Sticker'
),
};
} else if (message.contact != null) {
const name = message.contact.name ?? i18n('icu:unknownContact');
text ??= <UserText text={name} />;
icon ??= {
symbol: 'person-circle',
label: name,
};
}
// 4. Check attachments (make sure to check types like sticker/contact first)
if (message.attachment != null) { if (message.attachment != null) {
if (message.attachment.type === 'image') { if (message.attachment.type === 'image') {
text ??= i18n('icu:PinnedMessagesBar__MessagePreview__Text--Photo'); text ??= i18n('icu:PinnedMessagesBar__MessagePreview__Text--Photo');
@@ -426,35 +458,6 @@ function getMessagePreview(i18n: LocalizerType, message: PinMessage): Preview {
} else { } else {
throw missingCaseError(message.attachment); throw missingCaseError(message.attachment);
} }
} else if (message.contact != null) {
const name = message.contact.name ?? i18n('icu:unknownContact');
text ??= <UserText text={name} />;
icon ??= {
symbol: 'person-circle',
label: name,
};
} else if (message.payment) {
text ??= i18n('icu:PinnedMessagesBar__MessagePreview__Text--Payment');
icon ??= {
symbol: 'creditcard',
label: i18n(
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Payment'
),
};
} else if (message.poll != null) {
text ??= <UserText text={message.poll.question} />;
icon ??= {
symbol: 'poll',
label: i18n('icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Poll'),
};
} else if (message.sticker) {
text ??= i18n('icu:PinnedMessagesBar__MessagePreview__Text--Sticker');
icon ??= {
symbol: 'sticker',
label: i18n(
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Sticker'
),
};
} }
return { icon, text }; return { icon, text };

View File

@@ -16,6 +16,7 @@ import { pinnedMessagesCleanupService } from '../services/expiring/pinnedMessage
import { drop } from '../util/drop.std.js'; import { drop } from '../util/drop.std.js';
import type { AppendPinnedMessageResult } from '../sql/server/pinnedMessages.std.js'; import type { AppendPinnedMessageResult } from '../sql/server/pinnedMessages.std.js';
import * as Errors from '../types/errors.std.js'; import * as Errors from '../types/errors.std.js';
import { isGiftBadge } from '../state/selectors/message.preload.js';
const { AccessRequired } = Proto.AccessControl; const { AccessRequired } = Proto.AccessControl;
const { Role } = Proto.Member; const { Role } = Proto.Member;
@@ -209,6 +210,8 @@ function validatePinnedMessageTarget(
target: MessageModifierTarget, target: MessageModifierTarget,
sourceAci: AciString sourceAci: AciString
): { error: string } | null { ): { error: string } | null {
const message = target.targetMessage.attributes;
if (!isValidSenderAciForConversation(target.targetConversation, sourceAci)) { if (!isValidSenderAciForConversation(target.targetConversation, sourceAci)) {
return { error: 'Sender cannot send to target conversation' }; return { error: 'Sender cannot send to target conversation' };
} }
@@ -217,5 +220,9 @@ function validatePinnedMessageTarget(
return { error: 'Sender does not have access to edit group attributes' }; return { error: 'Sender does not have access to edit group attributes' };
} }
if (isGiftBadge(message)) {
return { error: 'Cannot pin gift badge messages' };
}
return null; return null;
} }

View File

@@ -64,7 +64,7 @@ export function getPinnedMessagesPreloadDataForConversation(
const [query, params] = sql` const [query, params] = sql`
SELECT * FROM pinnedMessages SELECT * FROM pinnedMessages
WHERE conversationId = ${conversationId} WHERE conversationId = ${conversationId}
ORDER BY pinnedAt DESC ORDER BY pinnedAt ASC
`; `;
return db return db

View File

@@ -946,7 +946,7 @@ export const getPropsForMessage = (
canDownload: canDownload(message, conversationSelector), canDownload: canDownload(message, conversationSelector),
canEndPoll: canEndPoll(message), canEndPoll: canEndPoll(message),
canForward: canForward(message), canForward: canForward(message),
canPinMessages: canPinMessages(conversation), canPinMessage: canPinMessage(conversation, message),
canReact: canReact(message, ourConversationId, conversationSelector), canReact: canReact(message, ourConversationId, conversationSelector),
canReply: canReply(message, ourConversationId, conversationSelector), canReply: canReply(message, ourConversationId, conversationSelector),
canRetry: hasErrors(message), canRetry: hasErrors(message),
@@ -2414,6 +2414,19 @@ export function canPinMessages(conversation: ConversationType): boolean {
return conversation.type === 'direct' || canEditGroupInfo(conversation); return conversation.type === 'direct' || canEditGroupInfo(conversation);
} }
export function canPinMessage(
conversation: ConversationType,
message: ReadonlyMessageAttributesType
): boolean {
if (!canPinMessages(conversation)) {
return false;
}
if (isGiftBadge(message)) {
return false;
}
return true;
}
function getHasMaxPinnedMessages( function getHasMaxPinnedMessages(
pinnedMessagesMessageIds: ReadonlyArray<string> pinnedMessagesMessageIds: ReadonlyArray<string>
) { ) {

View File

@@ -132,6 +132,10 @@ function getPinSender(props: MessagePropsType): PinSender {
}; };
} }
function getLastPinId(pins: ReadonlyArray<Pin>): PinnedMessageId | null {
return pins.at(-1)?.id ?? null;
}
function getPrevPinId( function getPrevPinId(
pins: ReadonlyArray<Pin>, pins: ReadonlyArray<Pin>,
pinnedMessageId: PinnedMessageId pinnedMessageId: PinnedMessageId
@@ -375,26 +379,45 @@ export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() {
useConversationsActions(); useConversationsActions();
const [current, setCurrent] = useState(() => { const [current, setCurrent] = useState(() => {
return pins.at(0)?.id ?? null; return getLastPinId(pins);
}); });
const isCurrentOutOfDate = useMemo(() => { const [prevPins, setPrevPins] = useState(pins);
if (pins !== prevPins) {
// Needed for `expectedCurrent` which might update `current` in the same render
setPrevPins(pins);
}
const expectedCurrent = useMemo(() => {
const latestPinId = getLastPinId(pins);
// If `current` is null, use the latest pin id if we have one.
if (current == null) { if (current == null) {
if (pins.length > 0) { return latestPinId;
return true;
}
return false;
} }
const hasMatch = pins.some(pin => { // If `current` is already the latest pin id, leave it.
return pin.id === current; if (current === latestPinId) {
}); return current;
}
return !hasMatch; // Update `current` if it no longer exists.
}, [current, pins]); const hasCurrent = pins.some(pin => pin.id === current);
if (!hasCurrent) {
return latestPinId;
}
if (isCurrentOutOfDate) { // Update `current` if it was previously the latest and there's a new latest.
setCurrent(pins.at(0)?.id ?? null); const prevLatestPinId = getLastPinId(prevPins);
if (prevLatestPinId === current && latestPinId != null) {
return latestPinId;
}
return current;
}, [current, pins, prevPins]);
if (current !== expectedCurrent) {
setCurrent(expectedCurrent);
} }
const handleCurrentChange = useCallback( const handleCurrentChange = useCallback(
@@ -410,9 +433,10 @@ export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() {
if (current == null) { if (current == null) {
return; return;
} }
const prevPinId = getPrevPinId(pins, current);
if (prevPinId != null) { const updatedCurrent = getPrevPinId(pins, current) ?? getLastPinId(pins);
setCurrent(prevPinId); if (updatedCurrent != null) {
setCurrent(updatedCurrent);
} }
}, },
[scrollToMessage, conversationId, pins, current] [scrollToMessage, conversationId, pins, current]