Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ef24c14e0 | ||
|
|
708e5c2c14 | ||
|
|
c4c54dad7a |
@@ -265,46 +265,6 @@ test.describe('local homeserver regression', () => {
|
||||
await expect.poll(threadUnread).toBe(false);
|
||||
});
|
||||
|
||||
test("a thread I don't follow stays unread while I view the room (#314)", async ({ page }) => {
|
||||
const room = await createRoom(alice, 'Unfollowed Thread Room', { invite: [bob.userId] });
|
||||
await joinRoom(bob, room);
|
||||
const inThread = (root: string) => ({
|
||||
'm.relates_to': {
|
||||
rel_type: 'm.thread',
|
||||
event_id: root,
|
||||
is_falling_back: true,
|
||||
'm.in_reply_to': { event_id: root },
|
||||
},
|
||||
});
|
||||
const root = await sendText(bob, room, "bob's thread root");
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await sendText(bob, room, 'reply in bob thread', inThread(root));
|
||||
await sendText(bob, room, 'newer main message 314');
|
||||
await expect(page.getByText('newer main message 314')).toBeVisible();
|
||||
const threadUnread = async () => {
|
||||
const filter = {
|
||||
room: {
|
||||
rooms: [room],
|
||||
timeline: { limit: 1, unread_thread_notifications: true, not_types: [uniq('x.')] },
|
||||
},
|
||||
};
|
||||
const sync = await api<{
|
||||
rooms: {
|
||||
join: Record<string, { unread_thread_notifications?: Record<string, unknown> }>;
|
||||
};
|
||||
}>(
|
||||
'GET',
|
||||
`/_matrix/client/v3/sync?timeout=0&filter=${enc(JSON.stringify(filter))}`,
|
||||
alice.token,
|
||||
);
|
||||
return root in (sync.rooms.join[room]?.unread_thread_notifications ?? {});
|
||||
};
|
||||
await page.waitForTimeout(2000); // let any receipt the room view would send go out
|
||||
// Viewing the room used to read every thread you don't follow (#314).
|
||||
expect(await threadUnread()).toBe(true);
|
||||
});
|
||||
|
||||
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
|
||||
const room = await createRoom(alice, 'Lightbox Room');
|
||||
const png = Buffer.from(
|
||||
|
||||
@@ -90,6 +90,9 @@ import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
|
||||
|
||||
const PIP_MIN_W = 200;
|
||||
const PIP_MIN_H = 112;
|
||||
/** [Gitea #323] Minimised PiP bar size. */
|
||||
const PIP_MINI_W = 220;
|
||||
const PIP_MINI_H = 40;
|
||||
/** Default PiP top: just below the 64px-ish room header. */
|
||||
const PIP_DEFAULT_TOP = 72;
|
||||
|
||||
@@ -1003,6 +1006,28 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
);
|
||||
|
||||
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
||||
// [Gitea #323] Minimised PiP: a small draggable bar (room name + expand)
|
||||
// instead of the video. Remembered; phones start minimised.
|
||||
const [pipMinimized, setPipMinimized] = useState<boolean>(() => {
|
||||
try {
|
||||
const v = localStorage.getItem('pip-minimized');
|
||||
if (v !== null) return v === '1';
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
return window.innerWidth < 600;
|
||||
});
|
||||
const pipFullSizeRef = React.useRef({ width: 280, height: 158 });
|
||||
const togglePipMinimized = useCallback(() => {
|
||||
setPipMinimized((m) => {
|
||||
try {
|
||||
localStorage.setItem('pip-minimized', m ? '0' : '1');
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
return !m;
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onFsChange);
|
||||
@@ -1103,6 +1128,26 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
}
|
||||
}, [pipMode, callVisible]);
|
||||
|
||||
// [Gitea #323] Apply the minimised / full PiP size (after the entry effect
|
||||
// below has positioned and sized it). Keeps the bar on screen.
|
||||
React.useEffect(() => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el || !pipMode) return;
|
||||
if (pipMinimized) {
|
||||
if (el.offsetHeight > PIP_MINI_H) {
|
||||
pipFullSizeRef.current = { width: el.offsetWidth, height: el.offsetHeight };
|
||||
}
|
||||
el.style.width = `${PIP_MINI_W}px`;
|
||||
el.style.height = `${PIP_MINI_H}px`;
|
||||
} else {
|
||||
el.style.width = `${pipFullSizeRef.current.width}px`;
|
||||
el.style.height = `${pipFullSizeRef.current.height}px`;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.style.left = `${Math.max(0, Math.min(rect.left, window.innerWidth - el.offsetWidth))}px`;
|
||||
el.style.top = `${Math.max(0, Math.min(rect.top, window.innerHeight - el.offsetHeight))}px`;
|
||||
}, [pipMode, pipMinimized, callEmbedRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pipMode) return;
|
||||
const onPipWindowResize = (): void => {
|
||||
@@ -1421,19 +1466,50 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
background: 'transparent',
|
||||
// Minimised: cover the frame so only the bar shows.
|
||||
background: pipMinimized ? color.Surface.Container : 'transparent',
|
||||
cursor: 'grab',
|
||||
// [Gitea #43] The call's "Share your screen?" prompt is drawn
|
||||
// inside the frame: let the click reach its Share button.
|
||||
pointerEvents: pipPromptOpen ? 'none' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: pipMinimized ? 'center' : 'flex-start',
|
||||
padding: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
||||
{document.fullscreenEnabled && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: config.space.S100,
|
||||
alignItems: 'center',
|
||||
...(pipMinimized ? { width: '100%' } : {}),
|
||||
}}
|
||||
>
|
||||
{/* Leftmost: the top-right corner is a resize handle. */}
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
aria-label={pipMinimized ? 'Expand call' : 'Minimise call'}
|
||||
aria-pressed={pipMinimized}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePipMinimized();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<Icon size="100" src={pipMinimized ? Icons.ChevronTop : Icons.Minus} />
|
||||
</IconButton>
|
||||
{!pipMinimized && document.fullscreenEnabled && (
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
@@ -1465,14 +1541,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
pointerEvents: 'none',
|
||||
...(pipMinimized
|
||||
? {
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
↗ Return to call
|
||||
{pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PipMuteOverlay callEmbed={callEmbed} />
|
||||
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
|
||||
{!pipMinimized && <PipMuteOverlay callEmbed={callEmbed} />}
|
||||
{(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => {
|
||||
const s = corner.includes('s');
|
||||
const e2 = corner.includes('e');
|
||||
const dots = [
|
||||
|
||||
@@ -169,7 +169,7 @@ test('private receipt flag uses ReadPrivate', async () => {
|
||||
// follows unread; an explicit mark-as-read still clears everything.
|
||||
const sender = (userId: string) => ({ getSender: () => userId });
|
||||
|
||||
test('passive: threads stay unread, followed or not; main receipt is scoped to main (#314)', async () => {
|
||||
test('passive: a thread I replied in stays unread; main receipt is scoped to main', async () => {
|
||||
const mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true });
|
||||
const other = thread('$other', evt('$r2'));
|
||||
const { mx, calls } = setup({
|
||||
@@ -181,9 +181,10 @@ test('passive: threads stay unread, followed or not; main receipt is scoped to m
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
// main-scoped (unthreaded would read the threads' replies), and no thread
|
||||
// receipts: [Gitea #314] unfollowed threads used to be read silently.
|
||||
[['b', false]],
|
||||
[
|
||||
['b', false], // main-scoped: unthreaded would also read $mine's reply
|
||||
['$r2', false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -202,7 +203,7 @@ test('passive: threads I started or was mentioned in stay unread', async () => {
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('passive: an unfollowed unread thread is left unread too (#314)', async () => {
|
||||
test('passive: no followed thread unread → main receipt stays unthreaded', async () => {
|
||||
const other = thread('$other', evt('$r2'), { rootEvent: sender('@bob:server') });
|
||||
const { mx, calls } = setup({
|
||||
timeline: [evt('a'), evt('b')],
|
||||
@@ -213,16 +214,10 @@ test('passive: an unfollowed unread thread is left unread too (#314)', async ()
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
[['b', false]],
|
||||
);
|
||||
});
|
||||
|
||||
test('passive: no thread unread → main receipt stays unthreaded', async () => {
|
||||
const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', threads: [] });
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
[['b', true]],
|
||||
[
|
||||
['b', true],
|
||||
['$r2', false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -254,10 +249,9 @@ test('passive: the thread open in the panel is left to the panel', async () => {
|
||||
threadUnread: { $open: 1, $other: 1 },
|
||||
});
|
||||
await markAsRead(mx, '!r:server', false, { passive: true, openThreadId: '$open' });
|
||||
// The open thread is left to the panel, and [#314] the other one stays unread.
|
||||
assert.deepEqual(
|
||||
calls.map((c) => c.eventId),
|
||||
[],
|
||||
['$r2'],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { MatrixClient, NotificationCountType, ReceiptType, Thread } from 'matrix-js-sdk';
|
||||
import { MatrixClient, NotificationCountType, ReceiptType, Room, Thread } from 'matrix-js-sdk';
|
||||
import { getSettings } from '../state/settings';
|
||||
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
|
||||
import { isThreadFollowed } from './room';
|
||||
|
||||
// isThreadFollowed lives in utils/room (getUnreadInfo needs it too).
|
||||
export { isThreadFollowed };
|
||||
/**
|
||||
* [Gitea #217] Threads the user is following: they started it, replied in it,
|
||||
* or were mentioned in it. `hasCurrentUserParticipated` comes from the server's
|
||||
* thread bundle and lags a reply we just sent, so our own loaded events count too.
|
||||
*/
|
||||
export function isThreadFollowed(room: Room, thread: Thread, userId: string | null): boolean {
|
||||
if (thread.hasCurrentUserParticipated) return true;
|
||||
if (userId && thread.rootEvent?.getSender() === userId) return true;
|
||||
if (userId && thread.timeline?.some((e) => e.getSender() === userId)) return true;
|
||||
return (
|
||||
(room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export type MarkAsReadOptions = {
|
||||
/**
|
||||
* [Gitea #217] The read comes from simply looking at the room (timeline at
|
||||
* the bottom and focused), not an explicit "mark as read". Threads stay
|
||||
* unread until opened (or "Mark all read"), like Slack and Discord.
|
||||
* [Gitea #314] That includes threads you don't follow; those no longer
|
||||
* count toward the room's unread dot (see getUnreadInfo), so leaving them
|
||||
* unread doesn't keep the room lit.
|
||||
* the bottom and focused), not an explicit "mark as read". Threads the user
|
||||
* follows stay unread until their panel is opened, like Slack and Discord;
|
||||
* the rest are cleared so they don't keep the room dot lit forever.
|
||||
*/
|
||||
passive?: boolean;
|
||||
/**
|
||||
@@ -58,9 +66,7 @@ export async function markAsRead(
|
||||
const threads = room.getThreads();
|
||||
const threadUnread = (thread: Thread) =>
|
||||
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
||||
// [Gitea #314] A passive read leaves every thread's state alone (it used to
|
||||
// silently read every thread you don't follow).
|
||||
const keepThread = (_thread: Thread) => passive;
|
||||
const keepThread = (thread: Thread) => passive && isThreadFollowed(room, thread, mx.getUserId());
|
||||
// [Gitea #217] An unthreaded receipt also reads every thread reply older than
|
||||
// it, so a passive read while a followed thread is unread must stay scoped
|
||||
// to the main timeline or the next main message would clear that thread.
|
||||
|
||||
@@ -928,42 +928,3 @@ test('getAllVersionsRoomCreator returns empty set when no create event', () => {
|
||||
test('EventTimeline.FORWARDS constant is available', () => {
|
||||
assert.equal(typeof EventTimeline.FORWARDS, 'string');
|
||||
});
|
||||
|
||||
test('getUnreadInfo: unfollowed threads do not light the room; followed and mentioned do (#314)', () => {
|
||||
const threads = [
|
||||
{ id: '$followed', hasCurrentUserParticipated: true },
|
||||
{ id: '$other' },
|
||||
{ id: '$mention' },
|
||||
];
|
||||
const counts: Record<string, { total: number; highlight: number }> = {
|
||||
$followed: { total: 2, highlight: 0 },
|
||||
$other: { total: 3, highlight: 0 },
|
||||
$mention: { total: 1, highlight: 1 },
|
||||
};
|
||||
const room = {
|
||||
roomId: '!r:x',
|
||||
// Server room totals include thread counts: 2 + 3 + 1 thread, 0 main.
|
||||
getUnreadNotificationCount: (type: NotificationCountType) =>
|
||||
type === NotificationCountType.Total ? 6 : 1,
|
||||
getThreads: () => threads,
|
||||
getThreadUnreadNotificationCount: (id: string, type: NotificationCountType) =>
|
||||
type === NotificationCountType.Total ? counts[id].total : counts[id].highlight,
|
||||
getEventReadUpTo: () => null,
|
||||
getLiveTimeline: () => ({ getEvents: () => [] }),
|
||||
} as unknown as Room;
|
||||
const me = { getUserId: () => '@me:x' } as unknown as MatrixClient;
|
||||
assert.deepEqual(getUnreadInfo(room, undefined, me), { roomId: '!r:x', highlight: 1, total: 3 });
|
||||
// Only an unfollowed thread is unread: the room isn't lit.
|
||||
counts.$followed.total = 0;
|
||||
counts.$mention = { total: 0, highlight: 0 };
|
||||
const onlyOther = {
|
||||
...room,
|
||||
getUnreadNotificationCount: (type: NotificationCountType) =>
|
||||
type === NotificationCountType.Total ? 3 : 0,
|
||||
} as unknown as Room;
|
||||
assert.deepEqual(getUnreadInfo(onlyOther, undefined, me), {
|
||||
roomId: '!r:x',
|
||||
highlight: 0,
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
NotificationCountType,
|
||||
RelationType,
|
||||
Room,
|
||||
Thread,
|
||||
RoomMember,
|
||||
TimelineEvents,
|
||||
IRoomTimelineData,
|
||||
@@ -419,20 +418,6 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #217] Threads the user is following: they started it, replied in it,
|
||||
* or were mentioned in it. `hasCurrentUserParticipated` comes from the server's
|
||||
* thread bundle and lags a reply we just sent, so our own loaded events count too.
|
||||
*/
|
||||
export function isThreadFollowed(room: Room, thread: Thread, userId: string | null): boolean {
|
||||
if (thread.hasCurrentUserParticipated) return true;
|
||||
if (userId && thread.rootEvent?.getSender() === userId) return true;
|
||||
if (userId && thread.timeline?.some((e) => e.getSender() === userId)) return true;
|
||||
return (
|
||||
(room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export const getUnreadInfo = (
|
||||
room: Room,
|
||||
mutedThreads?: Set<string>,
|
||||
@@ -454,18 +439,6 @@ export const getUnreadInfo = (
|
||||
if (highlight < 0) highlight = 0;
|
||||
}
|
||||
|
||||
// [Gitea #314] Threads you don't follow stay unread in the Threads panel
|
||||
// (viewing the room no longer reads them) but don't light the room's dot.
|
||||
// A mention makes a thread followed, so highlights are never subtracted.
|
||||
if (mx) {
|
||||
room.getThreads().forEach((thread) => {
|
||||
if (mutedThreads?.has(thread.id)) return;
|
||||
if (isThreadFollowed(room, thread, mx.getUserId())) return;
|
||||
total -= room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
||||
});
|
||||
if (total < 0) total = 0;
|
||||
}
|
||||
|
||||
const resolvedTotal = highlight > total ? highlight : total;
|
||||
|
||||
// Suppress a spurious Total when the room isn't really unread. Two safe cases,
|
||||
|
||||
Reference in New Issue
Block a user