Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec47d542dd | ||
|
|
674aef94fb |
@@ -12,6 +12,9 @@ regexes = [
|
|||||||
'''https?://matrix\.lotusguild\.org''',
|
'''https?://matrix\.lotusguild\.org''',
|
||||||
'''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''',
|
'''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''',
|
||||||
'''matrix\.lotusguild\.org''',
|
'''matrix\.lotusguild\.org''',
|
||||||
|
# Lotus Chat desktop updater's minisign PUBLIC key (published in the
|
||||||
|
# cinny-desktop README); utils/updateErrors.ts verifies Linux downloads with it.
|
||||||
|
'''RWRZZdyii05/NUcz337A5SAbiZK\+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l''',
|
||||||
]
|
]
|
||||||
paths = [
|
paths = [
|
||||||
'''config\.json''',
|
'''config\.json''',
|
||||||
|
|||||||
@@ -265,46 +265,6 @@ test.describe('local homeserver regression', () => {
|
|||||||
await expect.poll(threadUnread).toBe(false);
|
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 }) => {
|
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
|
||||||
const room = await createRoom(alice, 'Lightbox Room');
|
const room = await createRoom(alice, 'Lightbox Room');
|
||||||
const png = Buffer.from(
|
const png = Buffer.from(
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ test('private receipt flag uses ReadPrivate', async () => {
|
|||||||
// follows unread; an explicit mark-as-read still clears everything.
|
// follows unread; an explicit mark-as-read still clears everything.
|
||||||
const sender = (userId: string) => ({ getSender: () => userId });
|
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 mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true });
|
||||||
const other = thread('$other', evt('$r2'));
|
const other = thread('$other', evt('$r2'));
|
||||||
const { mx, calls } = setup({
|
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 });
|
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
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], // main-scoped: unthreaded would also read $mine's reply
|
||||||
[['b', false]],
|
['$r2', false],
|
||||||
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ test('passive: threads I started or was mentioned in stay unread', async () => {
|
|||||||
assert.equal(calls.length, 0);
|
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 other = thread('$other', evt('$r2'), { rootEvent: sender('@bob:server') });
|
||||||
const { mx, calls } = setup({
|
const { mx, calls } = setup({
|
||||||
timeline: [evt('a'), evt('b')],
|
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 });
|
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||||
[['b', false]],
|
[
|
||||||
);
|
['b', true],
|
||||||
});
|
['$r2', 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]],
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -254,10 +249,9 @@ test('passive: the thread open in the panel is left to the panel', async () => {
|
|||||||
threadUnread: { $open: 1, $other: 1 },
|
threadUnread: { $open: 1, $other: 1 },
|
||||||
});
|
});
|
||||||
await markAsRead(mx, '!r:server', false, { passive: true, openThreadId: '$open' });
|
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(
|
assert.deepEqual(
|
||||||
calls.map((c) => c.eventId),
|
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 { getSettings } from '../state/settings';
|
||||||
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
|
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 = {
|
export type MarkAsReadOptions = {
|
||||||
/**
|
/**
|
||||||
* [Gitea #217] The read comes from simply looking at the room (timeline at
|
* [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
|
* the bottom and focused), not an explicit "mark as read". Threads the user
|
||||||
* unread until opened (or "Mark all read"), like Slack and Discord.
|
* follows stay unread until their panel is opened, like Slack and Discord;
|
||||||
* [Gitea #314] That includes threads you don't follow; those no longer
|
* the rest are cleared so they don't keep the room dot lit forever.
|
||||||
* count toward the room's unread dot (see getUnreadInfo), so leaving them
|
|
||||||
* unread doesn't keep the room lit.
|
|
||||||
*/
|
*/
|
||||||
passive?: boolean;
|
passive?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -58,9 +66,7 @@ export async function markAsRead(
|
|||||||
const threads = room.getThreads();
|
const threads = room.getThreads();
|
||||||
const threadUnread = (thread: Thread) =>
|
const threadUnread = (thread: Thread) =>
|
||||||
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
||||||
// [Gitea #314] A passive read leaves every thread's state alone (it used to
|
const keepThread = (thread: Thread) => passive && isThreadFollowed(room, thread, mx.getUserId());
|
||||||
// silently read every thread you don't follow).
|
|
||||||
const keepThread = (_thread: Thread) => passive;
|
|
||||||
// [Gitea #217] An unthreaded receipt also reads every thread reply older than
|
// [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
|
// 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.
|
// 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', () => {
|
test('EventTimeline.FORWARDS constant is available', () => {
|
||||||
assert.equal(typeof EventTimeline.FORWARDS, 'string');
|
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,
|
NotificationCountType,
|
||||||
RelationType,
|
RelationType,
|
||||||
Room,
|
Room,
|
||||||
Thread,
|
|
||||||
RoomMember,
|
RoomMember,
|
||||||
TimelineEvents,
|
TimelineEvents,
|
||||||
IRoomTimelineData,
|
IRoomTimelineData,
|
||||||
@@ -419,20 +418,6 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
|
|||||||
return true;
|
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 = (
|
export const getUnreadInfo = (
|
||||||
room: Room,
|
room: Room,
|
||||||
mutedThreads?: Set<string>,
|
mutedThreads?: Set<string>,
|
||||||
@@ -454,18 +439,6 @@ export const getUnreadInfo = (
|
|||||||
if (highlight < 0) highlight = 0;
|
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;
|
const resolvedTotal = highlight > total ? highlight : total;
|
||||||
|
|
||||||
// Suppress a spurious Total when the room isn't really unread. Two safe cases,
|
// Suppress a spurious Total when the room isn't really unread. Two safe cases,
|
||||||
|
|||||||
@@ -69,13 +69,23 @@ test('install kinds: anything unknown keeps the in-app updater', async () => {
|
|||||||
test('package installs get their package manager’s command, not an Install button', async () => {
|
test('package installs get their package manager’s command, not an Install button', async () => {
|
||||||
const { packageUpdateHelp, isPackageManagedError } = await import('./updateErrors');
|
const { packageUpdateHelp, isPackageManagedError } = await import('./updateErrors');
|
||||||
const pacman = packageUpdateHelp('pacman')!;
|
const pacman = packageUpdateHelp('pacman')!;
|
||||||
// Not `pacman -U <url>`: that also wants `<url>.sig`, which we don't publish.
|
// [cinny-desktop #36] signature, then checksum, then install, in that order;
|
||||||
assert.equal(
|
// a local `pacman -U ./file` (not `<url>`, which also wants `<url>.sig`).
|
||||||
pacman.command,
|
const steps = pacman.command!.split(' && ');
|
||||||
'curl -LO https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst',
|
assert.equal(steps[0], 'cd "$(mktemp -d)"');
|
||||||
|
assert.match(
|
||||||
|
steps[1],
|
||||||
|
/-fLO \S+\/LotusChat-x86_64\.pkg\.tar\.zst -fLO \S+\/SHA256SUMS\.txt -fLO \S+\/SHA256SUMS\.txt\.minisig$/,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
steps[2],
|
||||||
|
'minisign -Vm SHA256SUMS.txt -P RWRZZdyii05/NUcz337A5SAbiZK+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l',
|
||||||
|
);
|
||||||
|
assert.equal(steps[3], 'sha256sum -c --ignore-missing SHA256SUMS.txt');
|
||||||
|
assert.equal(steps[4], 'sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst');
|
||||||
assert.match(pacman.url, /LotusChat-x86_64\.pkg\.tar\.zst$/);
|
assert.match(pacman.url, /LotusChat-x86_64\.pkg\.tar\.zst$/);
|
||||||
const deb = packageUpdateHelp('deb')!;
|
const deb = packageUpdateHelp('deb')!;
|
||||||
|
assert.match(deb.command!, /minisign -Vm SHA256SUMS\.txt/);
|
||||||
assert.match(deb.command!, /sudo apt install \.\/LotusChat-x86_64\.deb$/);
|
assert.match(deb.command!, /sudo apt install \.\/LotusChat-x86_64\.deb$/);
|
||||||
assert.equal(packageUpdateHelp('manual')?.command, undefined);
|
assert.equal(packageUpdateHelp('manual')?.command, undefined);
|
||||||
assert.equal(packageUpdateHelp('in-app'), undefined);
|
assert.equal(packageUpdateHelp('in-app'), undefined);
|
||||||
|
|||||||
@@ -64,23 +64,38 @@ export const toInstallKind = (value: unknown): InstallKind =>
|
|||||||
|
|
||||||
export type PackageUpdateHelp = { command?: string; url: string; download: string };
|
export type PackageUpdateHelp = { command?: string; url: string; download: string };
|
||||||
|
|
||||||
|
/** The desktop updater's minisign public key (cinny-desktop tauri.conf.json). */
|
||||||
|
export const RELEASE_MINISIGN_KEY = 'RWRZZdyii05/NUcz337A5SAbiZK+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [cinny-desktop #36] Download a Linux package and install it only after
|
||||||
|
* checking the release's minisign-signed SHA256SUMS.txt. Runs in a fresh temp
|
||||||
|
* directory so a stale SHA256SUMS.txt can't be picked up.
|
||||||
|
*/
|
||||||
|
const verifiedInstall = (file: string, install: string): string =>
|
||||||
|
[
|
||||||
|
`cd "$(mktemp -d)"`,
|
||||||
|
`curl -fLO ${RELEASE_DOWNLOAD}/${file} -fLO ${RELEASE_DOWNLOAD}/SHA256SUMS.txt -fLO ${RELEASE_DOWNLOAD}/SHA256SUMS.txt.minisig`,
|
||||||
|
`minisign -Vm SHA256SUMS.txt -P ${RELEASE_MINISIGN_KEY}`,
|
||||||
|
`sha256sum -c --ignore-missing SHA256SUMS.txt`,
|
||||||
|
`${install} ./${file}`,
|
||||||
|
].join(' && ');
|
||||||
|
|
||||||
export const packageUpdateHelp = (kind: InstallKind): PackageUpdateHelp | undefined => {
|
export const packageUpdateHelp = (kind: InstallKind): PackageUpdateHelp | undefined => {
|
||||||
if (kind === 'pacman') {
|
if (kind === 'pacman') {
|
||||||
const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`;
|
// Install the local file: `pacman -U <url>` also fetches `<url>.sig` and
|
||||||
// Download first, then install the local file: `pacman -U <url>` also
|
// fails without it (packages aren't signed for pacman), while a local file
|
||||||
// fetches `<url>.sig` and fails without it (we don't sign packages),
|
// falls under LocalFileSigLevel. Integrity comes from SHA256SUMS.txt.
|
||||||
// while a local file falls under LocalFileSigLevel (signature optional).
|
|
||||||
return {
|
return {
|
||||||
command: `curl -LO ${url} && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst`,
|
command: verifiedInstall('LotusChat-x86_64.pkg.tar.zst', 'sudo pacman -U'),
|
||||||
url,
|
url: `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`,
|
||||||
download: 'Download package',
|
download: 'Download package',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (kind === 'deb') {
|
if (kind === 'deb') {
|
||||||
const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`;
|
|
||||||
return {
|
return {
|
||||||
command: `curl -LO ${url} && sudo apt install ./LotusChat-x86_64.deb`,
|
command: verifiedInstall('LotusChat-x86_64.deb', 'sudo apt install'),
|
||||||
url,
|
url: `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`,
|
||||||
download: 'Download package',
|
download: 'Download package',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user