Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2f18e3d5 |
@@ -1,7 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
foldEdits,
|
||||
filterGroupsByDateRange,
|
||||
filterGroupsByMsgType,
|
||||
filterGroupsByPinned,
|
||||
@@ -110,47 +109,3 @@ test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
|
||||
['$2'],
|
||||
);
|
||||
});
|
||||
|
||||
const sr = (eventId: string, ts: number, content: Record<string, unknown>) =>
|
||||
({
|
||||
rank: 1,
|
||||
result: {
|
||||
event_id: eventId,
|
||||
room_id: '!r:x',
|
||||
origin_server_ts: ts,
|
||||
type: 'm.room.message',
|
||||
content,
|
||||
},
|
||||
}) as unknown as Parameters<typeof foldEdits>[0][number];
|
||||
|
||||
test('foldEdits: one row per message, showing the latest edit (#288)', () => {
|
||||
const edit = (id: string, ts: number, body: string) =>
|
||||
sr(id, ts, {
|
||||
body: `* ${body}`,
|
||||
'm.new_content': { body },
|
||||
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
|
||||
});
|
||||
const out = foldEdits([
|
||||
edit('$e2', 30, 'Tuesday 11am room B'),
|
||||
sr('$orig', 10, { body: 'Monday 10am room A' }),
|
||||
edit('$e1', 20, 'Tuesday 10am room A'),
|
||||
sr('$other', 5, { body: 'meeting notes' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
out.map((r) => r.result.event_id),
|
||||
['$orig', '$other'],
|
||||
);
|
||||
assert.deepEqual(out[0].result.content['m.new_content'], { body: 'Tuesday 11am room B' });
|
||||
assert.equal(out[0].result.content.body, 'Monday 10am room A');
|
||||
});
|
||||
|
||||
test('foldEdits: an edit whose original did not match stands in for it', () => {
|
||||
const out = foldEdits([
|
||||
sr('$e1', 20, {
|
||||
'm.new_content': { body: 'x' },
|
||||
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
|
||||
}),
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].result.event_id, '$orig');
|
||||
});
|
||||
|
||||
@@ -96,58 +96,10 @@ export const filterGroupsByDateRange = (
|
||||
.filter((group) => group.items.length > 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #288] Server search also returns `m.replace` events, so an edited
|
||||
* message showed once per version. Fold each edit into its message: one row,
|
||||
* at the first position any version ranked, showing the latest text and
|
||||
* pointing at the original event (so "jump" lands on the real message).
|
||||
*/
|
||||
export const foldEdits = (results: ISearchResult[]): ISearchResult[] => {
|
||||
const targetOf = (r: ISearchResult): string => {
|
||||
const rel = r.result.content?.['m.relates_to'];
|
||||
return rel?.rel_type === 'm.replace' && typeof rel.event_id === 'string'
|
||||
? rel.event_id
|
||||
: r.result.event_id;
|
||||
};
|
||||
const isEdit = (r: ISearchResult) => targetOf(r) !== r.result.event_id;
|
||||
const order: string[] = [];
|
||||
const originals = new Map<string, ISearchResult>();
|
||||
const latestEdit = new Map<string, ISearchResult>();
|
||||
results.forEach((r) => {
|
||||
const target = targetOf(r);
|
||||
if (!originals.has(target) && !latestEdit.has(target)) order.push(target);
|
||||
if (!isEdit(r)) {
|
||||
originals.set(target, r);
|
||||
return;
|
||||
}
|
||||
const prev = latestEdit.get(target);
|
||||
if (!prev || r.result.origin_server_ts > prev.result.origin_server_ts)
|
||||
latestEdit.set(target, r);
|
||||
});
|
||||
return order.map((target) => {
|
||||
const original = originals.get(target);
|
||||
const edit = latestEdit.get(target);
|
||||
const newContent = edit?.result.content?.['m.new_content'];
|
||||
if (original && !newContent) return original;
|
||||
if (original) {
|
||||
return {
|
||||
...original,
|
||||
result: {
|
||||
...original.result,
|
||||
content: { ...original.result.content, 'm.new_content': newContent },
|
||||
},
|
||||
};
|
||||
}
|
||||
// Only an edit matched: show it as its message.
|
||||
const only = edit as ISearchResult;
|
||||
return { ...only, result: { ...only.result, event_id: target } };
|
||||
});
|
||||
};
|
||||
|
||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||
const groups: ResultGroup[] = [];
|
||||
|
||||
foldEdits(results).forEach((item) => {
|
||||
results.forEach((item) => {
|
||||
const roomId = item.result.room_id;
|
||||
const resultItem: ResultItem = {
|
||||
rank: item.rank,
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
CALL_SESSION_HEARTBEAT_MS,
|
||||
clearCallSession,
|
||||
decideRejoin,
|
||||
othersInCall,
|
||||
rejoinToastBody,
|
||||
readCallSession,
|
||||
writeCallSession,
|
||||
} from '../utils/callRejoin';
|
||||
@@ -112,6 +114,27 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
||||
};
|
||||
dismissToast(TOAST_ID);
|
||||
clearCallSession();
|
||||
// [Gitea #302] A DM call the other person has since left is over;
|
||||
// starting it again would ring them, so just say so.
|
||||
const dm = l.directs.has(room.roomId);
|
||||
if (
|
||||
dm &&
|
||||
!othersInCall(
|
||||
session.memberships.map((m) => m.userId ?? ''),
|
||||
mx.getUserId() ?? '',
|
||||
)
|
||||
) {
|
||||
// Own id: clicking a toast removes it by id after its onClick runs.
|
||||
setToast({
|
||||
id: `${TOAST_ID}-ended`,
|
||||
iconSrc: Icons.Phone,
|
||||
displayName: 'Call ended',
|
||||
body: `${room.name ?? 'They'} already left the call.`,
|
||||
roomName: room.name ?? '',
|
||||
roomId: room.roomId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
|
||||
} catch {
|
||||
@@ -133,6 +156,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
||||
.filter((m) => m.userId === mx.getUserId())
|
||||
.map((m) => m.deviceId),
|
||||
memberCount: memberships.length,
|
||||
dm: latest.current.directs.has(room.roomId),
|
||||
});
|
||||
// The RTC session fills in a moment after sync; keep waiting while it is
|
||||
// empty unless this is the final (timed-out) look.
|
||||
@@ -152,7 +176,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
||||
id: TOAST_ID,
|
||||
iconSrc: Icons.Phone,
|
||||
displayName: 'Rejoin voice?',
|
||||
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
|
||||
body: rejoinToastBody(room.name, latest.current.directs.has(room.roomId)),
|
||||
roomName: room.name ?? '',
|
||||
roomId: room.roomId,
|
||||
sticky: true,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CALL_REJOIN_MAX_AGE_MS, decideRejoin, RejoinContext } from './callRejoin';
|
||||
import {
|
||||
CALL_REJOIN_MAX_AGE_MS,
|
||||
decideRejoin,
|
||||
othersInCall,
|
||||
rejoinToastBody,
|
||||
RejoinContext,
|
||||
} from './callRejoin';
|
||||
|
||||
const base: RejoinContext = {
|
||||
record: {
|
||||
@@ -54,3 +60,21 @@ describe('decideRejoin', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('decideRejoin: DM calls are never auto-rejoined, only offered (#302)', () => {
|
||||
assert.deepEqual(decideRejoin({ ...base, mode: 'auto', dm: true }), {
|
||||
action: 'ask',
|
||||
roomId: '!r',
|
||||
});
|
||||
assert.deepEqual(decideRejoin({ ...base, mode: 'auto' }), { action: 'auto', roomId: '!r' });
|
||||
});
|
||||
|
||||
it('rejoinToastBody and othersInCall (#302)', () => {
|
||||
assert.equal(
|
||||
rejoinToastBody('Bob Builder', true),
|
||||
'You were in a call with Bob Builder before the restart. Tap to rejoin.',
|
||||
);
|
||||
assert.match(rejoinToastBody('Voice Lounge', false), /in the call in Voice Lounge/);
|
||||
assert.equal(othersInCall(['@me:x'], '@me:x'), false);
|
||||
assert.equal(othersInCall(['@me:x', '@bob:x'], '@me:x'), true);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,8 @@ export type RejoinContext = {
|
||||
ownMemberDevices: string[];
|
||||
/** Number of call memberships in the room, ours included. */
|
||||
memberCount: number;
|
||||
/** A DM call: rejoining one may ring the other person. */
|
||||
dm?: boolean;
|
||||
};
|
||||
|
||||
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
|
||||
@@ -83,5 +85,18 @@ export function decideRejoin(ctx: RejoinContext): RejoinDecision {
|
||||
// Nobody there any more (our own stale membership doesn't count).
|
||||
const others = ctx.memberCount - ctx.ownMemberDevices.length;
|
||||
if (others <= 0) return { action: 'none' };
|
||||
// [Gitea #302] Never rejoin a DM call on our own: if the other person has
|
||||
// just left, starting it again would ring them. Ask instead.
|
||||
if (ctx.dm && mode === 'auto') return { action: 'ask', roomId: record.roomId };
|
||||
return { action: mode, roomId: record.roomId };
|
||||
}
|
||||
|
||||
/** Pure: whether anyone but us is still in the call (by call membership). */
|
||||
export const othersInCall = (memberUserIds: string[], myUserId: string): boolean =>
|
||||
memberUserIds.some((u) => u !== myUserId);
|
||||
|
||||
/** The rejoin toast's text; DMs name the person rather than "the call in <room>". */
|
||||
export const rejoinToastBody = (roomName: string | undefined, dm: boolean): string =>
|
||||
dm
|
||||
? `You were in a call with ${roomName ?? 'them'} before the restart. Tap to rejoin.`
|
||||
: `You were in the call in ${roomName ?? 'a room'} before the restart. Tap to rejoin.`;
|
||||
|
||||
Reference in New Issue
Block a user