Compare commits
7 Commits
b52926f7d8
...
ea7642f0bb
| Author | SHA1 | Date | |
|---|---|---|---|
| ea7642f0bb | |||
| 0fb3da20b9 | |||
| e460dbd34e | |||
| 0de2fd942f | |||
| 2603ca8e5c | |||
| 99a294791e | |||
| f62200ecd1 |
@@ -785,9 +785,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
|
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
|
||||||
|
|
||||||
// Historical / non-live windows are never "at bottom" of the room.
|
// Historical / non-live windows are never "at bottom" of the room.
|
||||||
|
// When we return to the live end, re-check scroll — IntersectionObserver may
|
||||||
|
// not re-fire if the anchor stayed intersecting across the transition.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!liveTimelineLinked || !rangeAtEnd) {
|
if (!liveTimelineLinked || !rangeAtEnd) {
|
||||||
setAtBottom(false);
|
setAtBottom(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl && isScrolledToBottom(scrollEl, 100)) {
|
||||||
|
setAtBottom(true);
|
||||||
}
|
}
|
||||||
}, [liveTimelineLinked, rangeAtEnd]);
|
}, [liveTimelineLinked, rangeAtEnd]);
|
||||||
|
|
||||||
@@ -1034,11 +1041,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (!targetEntry) return;
|
if (!targetEntry) return;
|
||||||
|
|
||||||
// Leave the live bottom immediately so Jump to Latest stays available.
|
// Leave the live bottom immediately so Jump to Latest stays available.
|
||||||
if (!targetEntry.isIntersecting || !atLiveEndRef.current) {
|
// Only intersection clears atBottom here; !atLiveEnd is handled by the
|
||||||
|
// liveTimelineLinked/rangeAtEnd effect so we don't stick false when the
|
||||||
|
// live end reconnects without a new intersection event.
|
||||||
|
if (!targetEntry.isIntersecting) {
|
||||||
setAtBottom(false);
|
setAtBottom(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!atLiveEndRef.current) return;
|
||||||
|
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
setLatestUnreadMessage(null);
|
setLatestUnreadMessage(null);
|
||||||
if (document.hasFocus()) {
|
if (document.hasFocus()) {
|
||||||
@@ -1145,7 +1157,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (restoringScrollRef.current) return;
|
if (restoringScrollRef.current) return;
|
||||||
window.cancelAnimationFrame(raf);
|
window.cancelAnimationFrame(raf);
|
||||||
raf = window.requestAnimationFrame(() => persistTimelineScroll(roomId));
|
raf = window.requestAnimationFrame(() => {
|
||||||
|
persistTimelineScroll(roomId);
|
||||||
|
// Keep Jump to Latest in sync with real scroll position when on the live end.
|
||||||
|
// IntersectionObserver alone can miss transitions and leave atBottom stuck.
|
||||||
|
if (atLiveEndRef.current) {
|
||||||
|
const at = isScrolledToBottom(scrollEl, 100);
|
||||||
|
setAtBottom(at);
|
||||||
|
if (at) setLatestUnreadMessage(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
scrollEl.addEventListener('scroll', onScroll, { passive: true });
|
scrollEl.addEventListener('scroll', onScroll, { passive: true });
|
||||||
@@ -1250,6 +1271,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
scrollToBottomRef.current.smooth,
|
scrollToBottomRef.current.smooth,
|
||||||
scrollToBottomRef.current.force
|
scrollToBottomRef.current.force
|
||||||
);
|
);
|
||||||
|
if (atLiveEndRef.current) {
|
||||||
|
setAtBottom(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [scrollToBottomCount]);
|
}, [scrollToBottomCount]);
|
||||||
@@ -2588,7 +2612,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
</MessageBase>
|
</MessageBase>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
<span ref={atBottomAnchorRef} />
|
{/* 1×1 so IntersectionObserver reliably detects the live bottom */}
|
||||||
|
<span
|
||||||
|
ref={atBottomAnchorRef}
|
||||||
|
aria-hidden
|
||||||
|
style={{ display: 'block', width: 1, height: 1 }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
{(showUnreadTop || returnToEventId) && (
|
{(showUnreadTop || returnToEventId) && (
|
||||||
|
|||||||
@@ -39,12 +39,14 @@ import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
|||||||
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
||||||
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
||||||
import {
|
import {
|
||||||
extractMetadataFromImage,
|
|
||||||
embedMetadataInImage,
|
embedMetadataInImage,
|
||||||
detectImageFormat,
|
detectImageFormat,
|
||||||
getMimeType,
|
getMimeType,
|
||||||
getExtension,
|
getExtension,
|
||||||
ImageMetadata,
|
ImageMetadata,
|
||||||
|
needsPngForMetadata,
|
||||||
|
convertImageDataToPng,
|
||||||
|
uint8ArrayToBlob,
|
||||||
} from '../../../utils/imageMetadata';
|
} from '../../../utils/imageMetadata';
|
||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
|
|
||||||
@@ -473,7 +475,35 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
try {
|
try {
|
||||||
// Fetch the raw uploaded image so we can embed existing banner/color metadata
|
// Re-embed existing banner/color/style into the new avatar when present
|
||||||
|
const metadata: ImageMetadata = {
|
||||||
|
banner: userBanner,
|
||||||
|
color: userColor,
|
||||||
|
avatarBorderColor: profileStyle.avatarBorderColor,
|
||||||
|
gradient: profileStyle.gradient,
|
||||||
|
};
|
||||||
|
const hasMetadata = Boolean(
|
||||||
|
metadata.color ||
|
||||||
|
metadata.banner ||
|
||||||
|
metadata.avatarBorderColor ||
|
||||||
|
metadata.gradient
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nothing to re-embed — use the already-uploaded MXC directly
|
||||||
|
if (!hasMetadata) {
|
||||||
|
await mx.setAvatarUrl(upload.mxc);
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (userId) {
|
||||||
|
const user = mx.getUser(userId);
|
||||||
|
if (user && user.avatarUrl !== upload.mxc) {
|
||||||
|
user.setAvatarUrl(upload.mxc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAvatarFile(undefined);
|
||||||
|
await syncUserAvatar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const httpUrl = mxcUrlToHttp(mx, upload.mxc, useAuthentication);
|
const httpUrl = mxcUrlToHttp(mx, upload.mxc, useAuthentication);
|
||||||
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
|
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
|
||||||
|
|
||||||
@@ -490,22 +520,25 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
|
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
|
||||||
const avatarData = await response.arrayBuffer();
|
let avatarData: ArrayBuffer | Uint8Array = await response.arrayBuffer();
|
||||||
|
|
||||||
const format = detectImageFormat(avatarData);
|
let format = detectImageFormat(avatarData);
|
||||||
if (format === 'unknown') throw new Error('Unsupported avatar image format');
|
if (format === 'unknown') throw new Error('Unsupported avatar image format');
|
||||||
|
|
||||||
// Re-embed the existing banner URL and profile color into the new avatar
|
// Banner / border / gradient only live in PNG tEXt — convert JPEG/WebP/GIF first
|
||||||
const metadata: ImageMetadata = {
|
if (format !== 'png' && needsPngForMetadata(metadata)) {
|
||||||
banner: userBanner,
|
const pngData = await convertImageDataToPng(avatarData);
|
||||||
color: userColor,
|
if (!pngData) throw new Error('Failed to convert avatar to PNG for metadata');
|
||||||
};
|
avatarData = pngData;
|
||||||
|
format = 'png';
|
||||||
|
}
|
||||||
|
|
||||||
const modifiedData = embedMetadataInImage(avatarData, metadata);
|
const modifiedData = embedMetadataInImage(avatarData, metadata);
|
||||||
if (!modifiedData) throw new Error('Failed to embed metadata in avatar');
|
if (!modifiedData) throw new Error('Failed to embed metadata in avatar');
|
||||||
|
|
||||||
const mimeType = getMimeType(format);
|
const mimeType = getMimeType(format);
|
||||||
const extension = getExtension(format);
|
const extension = getExtension(format);
|
||||||
const blob = new Blob([modifiedData], { type: mimeType });
|
const blob = uint8ArrayToBlob(modifiedData, mimeType);
|
||||||
const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType });
|
const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType });
|
||||||
|
|
||||||
await mx.setAvatarUrl(uploadResponse.content_uri);
|
await mx.setAvatarUrl(uploadResponse.content_uri);
|
||||||
@@ -525,7 +558,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
}
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
},
|
},
|
||||||
[mx, useAuthentication, userBanner, userColor, syncUserAvatar]
|
[mx, useAuthentication, userBanner, userColor, profileStyle, syncUserAvatar]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveBanner = async () => {
|
const handleRemoveBanner = async () => {
|
||||||
|
|||||||
@@ -170,22 +170,34 @@ type PushStatus = {
|
|||||||
registered: boolean;
|
registered: boolean;
|
||||||
distributor: string;
|
distributor: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
distributors: string[];
|
||||||
|
lastFailure: string;
|
||||||
|
autoStartBlocked: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Android-only section showing UnifiedPush registration status and controls. */
|
/** Android-only section showing UnifiedPush registration status and controls. */
|
||||||
function AndroidPushNotifications() {
|
function AndroidPushNotifications() {
|
||||||
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
|
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [lastError, setLastError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const s = await getBackgroundSyncStatus();
|
const s = await getBackgroundSyncStatus();
|
||||||
|
const distributors = Array.isArray(s?.distributors)
|
||||||
|
? s.distributors
|
||||||
|
: typeof s?.distributors === 'string' && s.distributors && s.distributors !== '[]'
|
||||||
|
? [s.distributors]
|
||||||
|
: [];
|
||||||
setStatus(
|
setStatus(
|
||||||
s
|
s
|
||||||
? {
|
? {
|
||||||
registered: s.registered,
|
registered: s.registered,
|
||||||
distributor: s.distributor || '',
|
distributor: s.distributor || '',
|
||||||
endpoint: s.endpoint || '',
|
endpoint: s.endpoint || '',
|
||||||
|
distributors,
|
||||||
|
lastFailure: s.lastFailure || '',
|
||||||
|
autoStartBlocked: Boolean(s.autoStartBlocked),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
@@ -198,8 +210,14 @@ function AndroidPushNotifications() {
|
|||||||
|
|
||||||
const [resetState, reset] = useAsyncCallback(
|
const [resetState, reset] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
await requestResetPushRegistration();
|
setLastError(undefined);
|
||||||
|
const result = await requestResetPushRegistration();
|
||||||
await refresh();
|
await refresh();
|
||||||
|
if (!result.success) {
|
||||||
|
setLastError(
|
||||||
|
'Selected distributor but did not get a push endpoint. In ntfy: enable UnifiedPush, allow unrestricted battery, then try Reset again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -214,6 +232,50 @@ function AndroidPushNotifications() {
|
|||||||
resetState.status === AsyncStatus.Loading ||
|
resetState.status === AsyncStatus.Loading ||
|
||||||
pingState.status === AsyncStatus.Loading;
|
pingState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const statusDescription = (() => {
|
||||||
|
if (loading) return 'Loading status…';
|
||||||
|
if (status === undefined) {
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
Failed to read push status.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.registered) {
|
||||||
|
return (
|
||||||
|
<Text as="span" size="T200">
|
||||||
|
{`Distributor: ${status.distributor || 'unknown'}`}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.lastFailure === 'AUTO_START_BLOCKED' || status.autoStartBlocked) {
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
Phone is blocking auto-start for Paarrot. Open App Boot / Auto-start settings, allow Paarrot (and ntfy), then tap Reset.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.lastFailure) {
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
{`Registration failed (${status.lastFailure}). Tap Reset and pick ntfy again.`}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.distributors.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
No UnifiedPush distributor found. Install ntfy from Play Store/F-Droid and enable UnifiedPush in ntfy settings.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
||||||
|
{`Found ${status.distributors.join(', ')} but not registered yet. Tap Reset and pick ntfy.`}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Android Push (UnifiedPush)</Text>
|
<Text size="L400">Android Push (UnifiedPush)</Text>
|
||||||
@@ -226,29 +288,20 @@ function AndroidPushNotifications() {
|
|||||||
<SettingTile
|
<SettingTile
|
||||||
title="Background Notifications"
|
title="Background Notifications"
|
||||||
description={
|
description={
|
||||||
loading ? (
|
|
||||||
'Loading status…'
|
|
||||||
) : status === undefined ? (
|
|
||||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
|
||||||
Failed to read push status.
|
|
||||||
</Text>
|
|
||||||
) : status.registered ? (
|
|
||||||
<>
|
<>
|
||||||
<Text as="span" size="T200">
|
{statusDescription}
|
||||||
{`Distributor: ${status.distributor || 'unknown'}`}
|
{lastError ? (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main, display: 'block' }} size="T200">
|
||||||
|
{lastError}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
|
||||||
Not registered. Tap "Reset" to choose a distributor app.
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
||||||
/>
|
/>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Change Distributor"
|
title="Change Distributor"
|
||||||
description="Re-open the UnifiedPush distributor selection dialog."
|
description="Shows a list of installed UnifiedPush apps (ntfy, etc.) and registers the one you pick."
|
||||||
after={
|
after={
|
||||||
<Button
|
<Button
|
||||||
size="300"
|
size="300"
|
||||||
|
|||||||
@@ -56,6 +56,19 @@ export const useRoomNavigate = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
|
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
|
||||||
|
|
||||||
|
// Prefer Direct for m.direct rooms unless we are already browsing a parent space
|
||||||
|
// of this room (keep space chrome). Opening via space while roomToParents is still
|
||||||
|
// catching up used to hit JoinBeforeNavigate even though the DM was listed.
|
||||||
|
if (mDirects.has(roomId)) {
|
||||||
|
const stayInSelectedSpace =
|
||||||
|
Boolean(spaceSelectedId) && orphanParents.includes(spaceSelectedId!);
|
||||||
|
if (!stayInSelectedSpace) {
|
||||||
|
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (orphanParents.length > 0) {
|
if (orphanParents.length > 0) {
|
||||||
let parentSpace: string;
|
let parentSpace: string;
|
||||||
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
|
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
|
||||||
@@ -73,11 +86,6 @@ export const useRoomNavigate = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mDirects.has(roomId)) {
|
|
||||||
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
getMimeType,
|
getMimeType,
|
||||||
getExtension,
|
getExtension,
|
||||||
ImageMetadata,
|
ImageMetadata,
|
||||||
|
convertImageDataToPng,
|
||||||
|
uint8ArrayToBlob,
|
||||||
} from '../utils/imageMetadata';
|
} from '../utils/imageMetadata';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,27 +139,39 @@ export function useUserBanner(): [
|
|||||||
throw new Error('Failed to fetch current avatar');
|
throw new Error('Failed to fetch current avatar');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect image format
|
// Detect image format — banner lives in PNG tEXt, so convert other formats first
|
||||||
const format = detectImageFormat(avatarData);
|
let workingData: ArrayBuffer | Uint8Array = avatarData;
|
||||||
|
let format = detectImageFormat(workingData);
|
||||||
|
|
||||||
if (format === 'unknown') {
|
if (format === 'unknown') {
|
||||||
throw new Error('Unsupported avatar image format');
|
throw new Error('Unsupported avatar image format');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (format !== 'png') {
|
||||||
|
console.log('[updateBanner] Converting', format, 'avatar to PNG for banner metadata');
|
||||||
|
const pngData = await convertImageDataToPng(workingData);
|
||||||
|
if (!pngData) {
|
||||||
|
throw new Error('Failed to convert avatar to PNG for banner metadata');
|
||||||
|
}
|
||||||
|
workingData = pngData;
|
||||||
|
format = 'png';
|
||||||
|
}
|
||||||
|
|
||||||
// Get existing metadata to preserve
|
// Get existing metadata to preserve
|
||||||
const existingMetadata = extractMetadataFromImage(avatarData);
|
const existingMetadata = extractMetadataFromImage(workingData);
|
||||||
console.log('[updateBanner] Existing metadata:', existingMetadata);
|
console.log('[updateBanner] Existing metadata:', existingMetadata);
|
||||||
|
|
||||||
// Modify image metadata with new banner, preserving color
|
// Modify image metadata with new banner, preserving color.
|
||||||
|
// Empty string clears banner (PNG embed drops falsy fields after stripping old chunks).
|
||||||
const newMetadata: ImageMetadata = {
|
const newMetadata: ImageMetadata = {
|
||||||
color: existingMetadata.color,
|
color: existingMetadata.color,
|
||||||
banner: newBanner,
|
banner: newBanner ?? '',
|
||||||
avatarBorderColor: existingMetadata.avatarBorderColor,
|
avatarBorderColor: existingMetadata.avatarBorderColor,
|
||||||
gradient: existingMetadata.gradient,
|
gradient: existingMetadata.gradient,
|
||||||
};
|
};
|
||||||
console.log('[updateBanner] New metadata to embed:', newMetadata);
|
console.log('[updateBanner] New metadata to embed:', newMetadata);
|
||||||
|
|
||||||
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
|
const newAvatarData = embedMetadataInImage(workingData, newMetadata);
|
||||||
|
|
||||||
if (!newAvatarData) {
|
if (!newAvatarData) {
|
||||||
throw new Error('Failed to embed banner in avatar metadata');
|
throw new Error('Failed to embed banner in avatar metadata');
|
||||||
@@ -167,7 +181,7 @@ export function useUserBanner(): [
|
|||||||
const verifyMetadata = extractMetadataFromImage(newAvatarData);
|
const verifyMetadata = extractMetadataFromImage(newAvatarData);
|
||||||
console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata);
|
console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata);
|
||||||
|
|
||||||
if (newBanner && verifyMetadata.banner !== newBanner) {
|
if ((newBanner || undefined) !== verifyMetadata.banner) {
|
||||||
console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner);
|
console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner);
|
||||||
throw new Error('Banner verification failed');
|
throw new Error('Banner verification failed');
|
||||||
}
|
}
|
||||||
@@ -176,7 +190,7 @@ export function useUserBanner(): [
|
|||||||
// Upload modified avatar with correct MIME type
|
// Upload modified avatar with correct MIME type
|
||||||
const mimeType = getMimeType(format);
|
const mimeType = getMimeType(format);
|
||||||
const extension = getExtension(format);
|
const extension = getExtension(format);
|
||||||
const blob = new Blob([newAvatarData], { type: mimeType });
|
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
|
||||||
|
|
||||||
const uploadResponse = await mx.uploadContent(blob, {
|
const uploadResponse = await mx.uploadContent(blob, {
|
||||||
name: `avatar.${extension}`,
|
name: `avatar.${extension}`,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
detectImageFormat,
|
detectImageFormat,
|
||||||
getMimeType,
|
getMimeType,
|
||||||
getExtension,
|
getExtension,
|
||||||
|
uint8ArrayToBlob,
|
||||||
} from '../utils/imageMetadata';
|
} from '../utils/imageMetadata';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,7 +177,7 @@ export function useUserColor(): [
|
|||||||
// Upload modified avatar with correct MIME type
|
// Upload modified avatar with correct MIME type
|
||||||
const mimeType = getMimeType(format);
|
const mimeType = getMimeType(format);
|
||||||
const extension = getExtension(format);
|
const extension = getExtension(format);
|
||||||
const blob = new Blob([newAvatarData], { type: mimeType });
|
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
|
||||||
const uploadResponse = await mx.uploadContent(blob, {
|
const uploadResponse = await mx.uploadContent(blob, {
|
||||||
name: `avatar.${extension}`,
|
name: `avatar.${extension}`,
|
||||||
type: mimeType,
|
type: mimeType,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { mxcUrlToHttp } from '../utils/matrix';import { getCurrentAccessToken }
|
|||||||
getExtension,
|
getExtension,
|
||||||
ImageMetadata,
|
ImageMetadata,
|
||||||
ProfileGradient,
|
ProfileGradient,
|
||||||
|
convertImageDataToPng,
|
||||||
|
uint8ArrayToBlob,
|
||||||
} from '../utils/imageMetadata';
|
} from '../utils/imageMetadata';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,14 +144,24 @@ export function useUserProfileStyle(): [
|
|||||||
throw new Error('Failed to fetch current avatar');
|
throw new Error('Failed to fetch current avatar');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect image format
|
// Border/gradient live in PNG tEXt — convert other formats first
|
||||||
const format = detectImageFormat(avatarData);
|
let workingData: ArrayBuffer | Uint8Array = avatarData;
|
||||||
|
let format = detectImageFormat(workingData);
|
||||||
if (format === 'unknown') {
|
if (format === 'unknown') {
|
||||||
throw new Error('Unsupported avatar image format');
|
throw new Error('Unsupported avatar image format');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (format !== 'png') {
|
||||||
|
const pngData = await convertImageDataToPng(workingData);
|
||||||
|
if (!pngData) {
|
||||||
|
throw new Error('Failed to convert avatar to PNG for style metadata');
|
||||||
|
}
|
||||||
|
workingData = pngData;
|
||||||
|
format = 'png';
|
||||||
|
}
|
||||||
|
|
||||||
// Get existing metadata to preserve
|
// Get existing metadata to preserve
|
||||||
const existingMetadata = extractMetadataFromImage(avatarData);
|
const existingMetadata = extractMetadataFromImage(workingData);
|
||||||
|
|
||||||
// Merge with new style
|
// Merge with new style
|
||||||
const newMetadata: ImageMetadata = {
|
const newMetadata: ImageMetadata = {
|
||||||
@@ -159,7 +171,7 @@ export function useUserProfileStyle(): [
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Embed updated metadata
|
// Embed updated metadata
|
||||||
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
|
const newAvatarData = embedMetadataInImage(workingData, newMetadata);
|
||||||
if (!newAvatarData) {
|
if (!newAvatarData) {
|
||||||
throw new Error('Failed to embed style in avatar metadata');
|
throw new Error('Failed to embed style in avatar metadata');
|
||||||
}
|
}
|
||||||
@@ -167,7 +179,7 @@ export function useUserProfileStyle(): [
|
|||||||
// Upload modified avatar with correct MIME type
|
// Upload modified avatar with correct MIME type
|
||||||
const mimeType = getMimeType(format);
|
const mimeType = getMimeType(format);
|
||||||
const extension = getExtension(format);
|
const extension = getExtension(format);
|
||||||
const blob = new Blob([newAvatarData], { type: mimeType });
|
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
|
||||||
|
|
||||||
const uploadResponse = await mx.uploadContent(blob, {
|
const uploadResponse = await mx.uploadContent(blob, {
|
||||||
name: `avatar.${extension}`,
|
name: `avatar.${extension}`,
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { decodeRouteParam } from '../../pathUtils';
|
import { decodeRouteParam } from '../../pathUtils';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||||
import { useDirectRooms } from './useDirectRooms';
|
import { useDirectRooms } from './useDirectRooms';
|
||||||
|
import { mDirectAtom } from '../../../state/mDirectList';
|
||||||
|
import { Membership } from '../../../../types/matrix/room';
|
||||||
|
|
||||||
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
|
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const rooms = useDirectRooms();
|
const rooms = useDirectRooms();
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
|
||||||
const { roomIdOrAlias: rawRoomIdOrAlias, eventId: rawEventId } = useParams();
|
const { roomIdOrAlias: rawRoomIdOrAlias, eventId: rawEventId } = useParams();
|
||||||
const roomIdOrAlias = decodeRouteParam(rawRoomIdOrAlias);
|
const roomIdOrAlias = decodeRouteParam(rawRoomIdOrAlias);
|
||||||
@@ -17,7 +21,13 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||||||
const roomId = useSelectedRoom();
|
const roomId = useSelectedRoom();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
if (!room || !rooms.includes(room.roomId)) {
|
// useDirectRooms can lag m.direct / allRooms during catch-up; allow joined m.direct rooms.
|
||||||
|
const isJoinedDirect =
|
||||||
|
Boolean(room) &&
|
||||||
|
room!.getMyMembership() === Membership.Join &&
|
||||||
|
(rooms.includes(room!.roomId) || mDirects.has(room!.roomId));
|
||||||
|
|
||||||
|
if (!room || !isJoinedDirect) {
|
||||||
return (
|
return (
|
||||||
<JoinBeforeNavigate
|
<JoinBeforeNavigate
|
||||||
roomIdOrAlias={roomIdOrAlias ?? rawRoomIdOrAlias!}
|
roomIdOrAlias={roomIdOrAlias ?? rawRoomIdOrAlias!}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode, useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { decodeRouteParam } from '../../pathUtils';
|
import { decodeRouteParam } from '../../pathUtils';
|
||||||
@@ -30,7 +30,31 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||||||
const roomId = useSelectedRoom();
|
const roomId = useSelectedRoom();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
if (!room || !allRooms.includes(room.roomId)) {
|
const isJoined = Boolean(room && allRooms.includes(room.roomId));
|
||||||
|
const isSpaceChild = Boolean(room && getSpaceChildren(space).includes(room.roomId));
|
||||||
|
const hasParentMapping = Boolean(
|
||||||
|
room && getAllParents(roomToParents, room.roomId).has(space.roomId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// roomToParents can lag behind live space state during catch-up sync.
|
||||||
|
// If the room is clearly a child of this space, backfill the map and open it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!room || !isJoined || hasParentMapping || !isSpaceChild) return;
|
||||||
|
setRoomToParents({
|
||||||
|
type: 'PUT',
|
||||||
|
parent: space.roomId,
|
||||||
|
children: [room.roomId],
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
room,
|
||||||
|
isJoined,
|
||||||
|
hasParentMapping,
|
||||||
|
isSpaceChild,
|
||||||
|
space.roomId,
|
||||||
|
setRoomToParents,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!room || !isJoined) {
|
||||||
// room is not joined
|
// room is not joined
|
||||||
return (
|
return (
|
||||||
<JoinBeforeNavigate
|
<JoinBeforeNavigate
|
||||||
@@ -50,16 +74,7 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
|
if (!hasParentMapping && !isSpaceChild) {
|
||||||
if (getSpaceChildren(space).includes(room.roomId)) {
|
|
||||||
// fill missing roomToParent mapping
|
|
||||||
setRoomToParents({
|
|
||||||
type: 'PUT',
|
|
||||||
parent: space.roomId,
|
|
||||||
children: [room.roomId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JoinBeforeNavigate
|
<JoinBeforeNavigate
|
||||||
roomIdOrAlias={roomIdOrAlias ?? rawRoomIdOrAlias!}
|
roomIdOrAlias={roomIdOrAlias ?? rawRoomIdOrAlias!}
|
||||||
|
|||||||
@@ -103,6 +103,23 @@ export const useBindRoomToParentsAtom = (
|
|||||||
setRoomToParents({ type: 'DELETE', roomId: childId });
|
setRoomToParents({ type: 'DELETE', roomId: childId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create type can arrive after the room is already in the store. Without this,
|
||||||
|
// spaces are skipped on INITIALIZE (isSpace false) and children stay unmapped
|
||||||
|
// until an unrelated SpaceChild event fires — DMs/channels look listed but
|
||||||
|
// SpaceRouteRoomProvider blocks on missing roomToParents.
|
||||||
|
if (mEvent.getType() === StateEvent.RoomCreate) {
|
||||||
|
const roomId = mEvent.getRoomId();
|
||||||
|
const room = roomId ? mx.getRoom(roomId) : null;
|
||||||
|
if (room && isSpace(room) && room.getMyMembership() !== Membership.Invite) {
|
||||||
|
setRoomToParents({
|
||||||
|
type: 'PUT',
|
||||||
|
parent: room.roomId,
|
||||||
|
children: getSpaceChildren(room),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ type UnifiedPushStatus = {
|
|||||||
registered: boolean;
|
registered: boolean;
|
||||||
distributor: string;
|
distributor: string;
|
||||||
distributors: string[] | string;
|
distributors: string[] | string;
|
||||||
|
lastFailure?: string;
|
||||||
|
/** OEM App Boot / AUTO_START is blocking distributor broadcasts (e.g. TCL). */
|
||||||
|
autoStartBlocked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UnifiedPushEndpointEvent = {
|
type UnifiedPushEndpointEvent = {
|
||||||
@@ -55,6 +58,8 @@ interface MatrixBackgroundSyncPlugin {
|
|||||||
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
||||||
/** Returns fetch state and current UnifiedPush registration details. */
|
/** Returns fetch state and current UnifiedPush registration details. */
|
||||||
getStatus(): Promise<UnifiedPushStatus>;
|
getStatus(): Promise<UnifiedPushStatus>;
|
||||||
|
/** Native Matrix gateway discovery for a UnifiedPush endpoint (avoids WebView CORS). */
|
||||||
|
resolveUnifiedPushGateway(options: { endpoint: string }): Promise<{ gatewayUrl: string }>;
|
||||||
addListener(
|
addListener(
|
||||||
eventName: 'unifiedPushNewEndpoint',
|
eventName: 'unifiedPushNewEndpoint',
|
||||||
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
||||||
@@ -141,6 +146,17 @@ const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[]
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
|
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
|
||||||
|
// Prefer native HTTP — ntfy/Matrix gateway responses omit CORS headers, so
|
||||||
|
// WebView fetch fails with TypeError: Failed to fetch and wrongly falls back.
|
||||||
|
try {
|
||||||
|
const { registerPlugin } = await loadCapacitorCore();
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
|
const result = await MatrixBackgroundSync.resolveUnifiedPushGateway({ endpoint });
|
||||||
|
if (result?.gatewayUrl) return result.gatewayUrl;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] Native UnifiedPush gateway discovery failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const discoveryUrl = new URL(endpoint);
|
const discoveryUrl = new URL(endpoint);
|
||||||
discoveryUrl.pathname = '/_matrix/push/v1/notify';
|
discoveryUrl.pathname = '/_matrix/push/v1/notify';
|
||||||
@@ -529,7 +545,23 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
|
|||||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
const result = await MatrixBackgroundSync.requestDistributorSetup();
|
const result = await MatrixBackgroundSync.requestDistributorSetup();
|
||||||
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
|
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
|
||||||
return result ?? { success: false };
|
if (!result?.success) return { success: false };
|
||||||
|
|
||||||
|
// Endpoint arrives asynchronously from the distributor after register().
|
||||||
|
for (let i = 0; i < 40; i += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
const status = await getBackgroundSyncStatus();
|
||||||
|
if (status?.registered && status.endpoint) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (status?.lastFailure) {
|
||||||
|
console.warn('[BackgroundSync] Registration failed while waiting:', status.lastFailure);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[BackgroundSync] Distributor selected but no endpoint received in time');
|
||||||
|
return { success: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
|
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
|
||||||
return { success: false };
|
return { success: false };
|
||||||
|
|||||||
@@ -258,6 +258,59 @@ export function embedColorInImage(imageData: ArrayBuffer | Uint8Array, color: st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when metadata includes fields that only PNG tEXt chunks can store.
|
||||||
|
*/
|
||||||
|
export function needsPngForMetadata(metadata: ImageMetadata): boolean {
|
||||||
|
return Boolean(metadata.banner || metadata.avatarBorderColor || metadata.gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert image data to PNG via canvas (needed for banner/border/gradient metadata).
|
||||||
|
*/
|
||||||
|
export async function convertImageDataToPng(
|
||||||
|
imageData: ArrayBuffer | Uint8Array
|
||||||
|
): Promise<Uint8Array | null> {
|
||||||
|
const bytes = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
||||||
|
const format = detectImageFormat(bytes);
|
||||||
|
if (format === 'png') return bytes;
|
||||||
|
if (format === 'unknown') return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = uint8ArrayToBlob(bytes, getMimeType(format));
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
|
|
||||||
|
const pngBlob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(resolve, 'image/png');
|
||||||
|
});
|
||||||
|
if (!pngBlob) return null;
|
||||||
|
return new Uint8Array(await pngBlob.arrayBuffer());
|
||||||
|
} finally {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[convertImageDataToPng] Conversion failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Blob from Uint8Array without relying on BufferSource/BlobPart quirks
|
||||||
|
* across TS DOM lib / Electron versions (avoids SharedArrayBuffer typing issues).
|
||||||
|
*/
|
||||||
|
export function uint8ArrayToBlob(data: Uint8Array, type: string): Blob {
|
||||||
|
const copy = new ArrayBuffer(data.byteLength);
|
||||||
|
new Uint8Array(copy).set(data);
|
||||||
|
return new Blob([copy], { type });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Embed paarrot metadata (color and/or banner) into image data
|
* Embed paarrot metadata (color and/or banner) into image data
|
||||||
* Preserves existing metadata that is not being updated
|
* Preserves existing metadata that is not being updated
|
||||||
@@ -270,18 +323,27 @@ export function embedMetadataInImage(
|
|||||||
metadata: ImageMetadata
|
metadata: ImageMetadata
|
||||||
): Uint8Array | null {
|
): Uint8Array | null {
|
||||||
const format = detectImageFormat(imageData);
|
const format = detectImageFormat(imageData);
|
||||||
|
const bytes = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'png':
|
case 'png':
|
||||||
return embedMetadataInPNG(imageData, metadata);
|
return embedMetadataInPNG(imageData, metadata);
|
||||||
// For other formats, only embed color for now
|
// Non-PNG: color via format-specific chunks; banner/border/gradient need PNG (convert first)
|
||||||
case 'webp':
|
case 'webp':
|
||||||
case 'jpeg':
|
case 'jpeg':
|
||||||
case 'gif':
|
case 'gif':
|
||||||
|
if (needsPngForMetadata(metadata)) {
|
||||||
|
console.warn(
|
||||||
|
'[embedMetadataInImage] Banner/border/gradient require PNG; convert with convertImageDataToPng first. Got:',
|
||||||
|
format
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (metadata.color) {
|
if (metadata.color) {
|
||||||
return embedColorInImage(imageData, metadata.color);
|
return embedColorInImage(imageData, metadata.color);
|
||||||
}
|
}
|
||||||
return null;
|
// Supported format with nothing to embed — succeed as a no-op so avatar upload isn't blocked
|
||||||
|
return bytes;
|
||||||
default:
|
default:
|
||||||
console.warn('[embedMetadataInImage] Unknown image format, cannot embed metadata');
|
console.warn('[embedMetadataInImage] Unknown image format, cannot embed metadata');
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -887,6 +887,12 @@ body.stationery-dark-theme {
|
|||||||
width: calc(100% + 25px) !important;
|
width: calc(100% + 25px) !important;
|
||||||
max-width: none !important;
|
max-width: none !important;
|
||||||
box-sizing: border-box !important;
|
box-sizing: border-box !important;
|
||||||
|
/* Gutter is only for post-it overflow paint — do not steal clicks from page-nav */
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stationery [data-sidebar-scroll] > * {
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stationery [data-sidebar] > *,
|
.stationery [data-sidebar] > *,
|
||||||
|
|||||||
Reference in New Issue
Block a user