Fix image viewer, reset parse typings for now, fix loading messages on chan switch

This commit is contained in:
Max Leiter 2022-05-30 12:33:32 -07:00
parent 4740d1d574
commit 9a57e218b4
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
18 changed files with 187 additions and 178 deletions

View file

@ -17,12 +17,12 @@
<span class="parted-channel-icon" />
</span>
<span class="close-tooltip tooltipped tooltipped-w" aria-label="Leave">
<button class="close" aria-label="Leave" @click.stop="close()" />
<button class="close" aria-label="Leave" @click.stop="close" />
</span>
</template>
<template v-else>
<span class="close-tooltip tooltipped tooltipped-w" aria-label="Close">
<button class="close" aria-label="Close" @click.stop="close()" />
<button class="close" aria-label="Close" @click.stop="close" />
</span>
</template>
</ChannelWrapper>
@ -35,30 +35,6 @@ import useCloseChannel from "../js/hooks/use-close-channel";
import {ClientChan, ClientNetwork} from "../js/types";
import ChannelWrapper from "./ChannelWrapper.vue";
// export default defineComponent({
// name: "Channel",
// components: {
// ChannelWrapper,
// },
// props: {
// network: {type: Object as PropType<ClientNetwork>, required: true},
// channel: {type: Object as PropType<ClientChan>, required: true},
// active: Boolean,
// isFiltering: Boolean,
// },
// computed: {
// unreadCount(): string {
// return roundBadgeNumber(this.channel.unread);
// },
// },
// methods: {
// close(): void {
// this.$root?.closeChannel(this.channel);
// },
// },
// });
//
export default defineComponent({
name: "Channel",
components: {

View file

@ -42,7 +42,6 @@ import {switchToChannel} from "../js/router";
export default defineComponent({
name: "ChannelWrapper",
props: {
network: {
type: Object as PropType<ClientNetwork>,
@ -82,7 +81,7 @@ export default defineComponent({
}
}
return `${type}: ${props.channel.name} ${extra.length ? `(${extra.join(", ")})` : ""}`;
return `${type}: ${props.channel.name}${extra.length ? `(${extra.join(", ")})` : ""}`;
};
const click = () => {

View file

@ -105,6 +105,7 @@
:network="network"
:channel="channel"
:focused="focused"
@scrolled-to-bottom="onScrolledToBottom"
/>
</div>
</div>
@ -174,10 +175,12 @@ export default defineComponent({
return undefined;
});
const onScrolledToBottom = (data: boolean) => {
props.channel.scrolledToBottom = data;
};
const channelChanged = () => {
// Triggered when active channel is set or changed
// props.channel.highlight = 0;
// props.channel.unread = 0;
emit("channel-changed", props.channel);
socket.emit("open", props.channel.id);
@ -268,6 +271,7 @@ export default defineComponent({
topicInput,
specialComponent,
editTopicRef,
onScrolledToBottom,
hideUserVisibleError,
editTopic,
saveTopic,

View file

@ -35,8 +35,8 @@
v-for="user in users"
:key="user.original.nick + '-search'"
:on-hover="hoverUser"
:active="user.original === (activeUser as any)"
:user="user.original"
:active="user.original === activeUser"
:user="(user.original as any)"
v-html="user.string"
/>
</template>
@ -241,6 +241,7 @@ export default defineComponent({
groupedUsers,
userSearchInput,
activeUser,
userlist,
setUserSearchInput,
getModeClass,

View file

@ -326,7 +326,9 @@ export default defineComponent({
// 2. If image is zoomed in, simply dragging it will move it around
const onImageMouseDown = (e: MouseEvent) => {
// todo: ignore if in touch event currently?
// only left mouse
// TODO: e.buttons?
if (e.which !== 1) {
return;
}
@ -467,6 +469,7 @@ export default defineComponent({
nextImage,
onImageTouchStart,
computeImageStyles,
viewer,
};
},
});

View file

@ -146,7 +146,7 @@ import eventbus from "../js/eventbus";
import friendlysize from "../js/helpers/friendlysize";
import {useStore} from "../js/store";
import type {ClientChan, ClientLinkPreview} from "../js/types";
import {imageViewerKey, useImageViewer} from "./App.vue";
import {imageViewerKey} from "./App.vue";
export default defineComponent({
name: "LinkPreview",
@ -166,6 +166,7 @@ export default defineComponent({
const showMoreButton = ref(false);
const isContentShown = ref(false);
const imageViewer = inject(imageViewerKey);
const content = ref<HTMLDivElement | null>(null);
const container = ref<HTMLDivElement | null>(null);
@ -234,9 +235,8 @@ export default defineComponent({
const onThumbnailClick = (e: MouseEvent) => {
e.preventDefault();
const imageViewer = inject(imageViewerKey);
if (!imageViewer.value) {
if (!imageViewer?.value) {
return;
}

View file

@ -26,7 +26,6 @@ export default defineComponent({
const onClick = () => {
props.link.shown = !props.link.shown;
emit("toggle-link-preview", props.link, props.message);
// this.$parent.$emit("toggle-link-preview", this.link, this.$parent.message);
};
return {

View file

@ -25,7 +25,7 @@
<div class="mentions-info">
<div>
<span class="from">
<Username :user="message.from" />
<Username :user="(message.from as any)" />
<template v-if="message.channel">
in {{ message.channel.channel.name }} on
{{ message.channel.network.name }}

View file

@ -104,7 +104,8 @@ export default defineComponent({
channel: {type: Object as PropType<ClientChan>, required: true},
focused: String,
},
setup(props) {
emits: ["scrolled-to-bottom"],
setup(props, {emit}) {
const store = useStore();
const chat = ref<HTMLDivElement | null>(null);
@ -116,7 +117,7 @@ export default defineComponent({
const jumpToBottom = () => {
skipNextScrollEvent.value = true;
props.channel.scrolledToBottom = true;
emit("scrolled-to-bottom", true);
const el = chat.value;
@ -358,7 +359,7 @@ export default defineComponent({
return;
}
props.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
emit("scrolled-to-bottom", el.scrollHeight - el.scrollTop - el.offsetHeight <= 30);
};
const handleResize = () => {
@ -385,7 +386,7 @@ export default defineComponent({
watch(
() => props.channel.id,
() => {
props.channel.scrolledToBottom = true;
emit("scrolled-to-bottom", true);
// Re-add the intersection observer to trigger the check again on channel switch
// Otherwise if last channel had the button visible, switching to a new channel won't trigger the history
@ -434,6 +435,7 @@ export default defineComponent({
chat,
store,
onShowMoreClick,
loadMoreButton,
onCopy,
condensedMessages,
shouldDisplayDateMarker,

View file

@ -122,6 +122,40 @@ export default defineComponent({
store.commit("sidebarOpen", state);
};
const onTouchEnd = () => {
if (!touchStartPos.value?.screenX || !touchCurPos.value?.screenX) {
return;
}
const diff = touchCurPos.value.screenX - touchStartPos.value.screenX;
const absDiff = Math.abs(diff);
if (
absDiff > menuWidth.value / 2 ||
(Date.now() - touchStartTime.value < 180 && absDiff > 50)
) {
toggle(diff > 0);
}
document.body.removeEventListener("touchmove", onTouchMove);
document.body.removeEventListener("touchend", onTouchEnd);
store.commit("sidebarDragging", false);
if (sidebar.value) {
sidebar.value.style.transform = "";
}
if (props.overlay) {
props.overlay.style.opacity = "";
}
touchStartPos.value = null;
touchCurPos.value = null;
touchStartTime.value = 0;
menuIsMoving.value = false;
};
const onTouchMove = (e: TouchEvent) => {
const touch = (touchCurPos.value = e.touches.item(0));
@ -173,42 +207,16 @@ export default defineComponent({
sidebar.value.style.transform = "translate3d(" + distX.toString() + "px, 0, 0)";
}
props.overlay.style.opacity = `${distX / menuWidth.value}`;
};
const onTouchEnd = () => {
if (!touchStartPos.value?.screenX || !touchCurPos.value?.screenX) {
return;
if (props.overlay) {
props.overlay.style.opacity = `${distX / menuWidth.value}`;
}
const diff = touchCurPos.value.screenX - touchStartPos.value.screenX;
const absDiff = Math.abs(diff);
if (
absDiff > menuWidth.value / 2 ||
(Date.now() - touchStartTime.value < 180 && absDiff > 50)
) {
toggle(diff > 0);
}
document.body.removeEventListener("touchmove", onTouchMove);
document.body.removeEventListener("touchend", onTouchEnd);
store.commit("sidebarDragging", false);
if (sidebar.value) {
sidebar.value.style.transform = "";
}
props.overlay.style.opacity = "";
touchStartPos.value = null;
touchCurPos.value = null;
touchStartTime.value = 0;
menuIsMoving.value = false;
};
const onTouchStart = (e: TouchEvent) => {
if (!sidebar.value) {
return;
}
touchStartPos.value = touchCurPos.value = e.touches.item(0);
if (e.touches.length !== 1) {
@ -216,7 +224,7 @@ export default defineComponent({
return;
}
const styles = window.getComputedStyle(this.$refs.sidebar);
const styles = window.getComputedStyle(sidebar.value);
menuWidth.value = parseFloat(styles.width);
menuIsAbsolute.value = styles.position === "absolute";

View file

@ -17,11 +17,10 @@ import eventbus from "../js/eventbus";
import colorClass from "../js/helpers/colorClass";
import type {ClientChan, ClientNetwork, ClientUser} from "../js/types";
type UsernameUser = Partial<UserInMessage> &
Partial<{
nick: string;
mode: string;
}>;
type UsernameUser = Partial<UserInMessage> & {
mode?: string;
nick: string;
};
export default defineComponent({
name: "Username",
@ -47,8 +46,7 @@ export default defineComponent({
return props.user.mode;
});
const nickColor = computed(() => colorClass(props.user.nick!));
const nickColor = computed(() => colorClass(props.user.nick));
const hover = () => {
if (props.onHover) {

View file

@ -38,7 +38,7 @@ function sortParts(a: Part, b: Part) {
return a.start - b.start || b.end - a.end;
}
type MergedParts = (TextPart | NamePart | EmojiPart | ChannelPart | LinkPart)[];
export type MergedParts = (TextPart | NamePart | EmojiPart | ChannelPart | LinkPart)[];
// Merge the style fragments within the text parts, taking into account
// boundaries and text sections that have not matched to links or channels.

View file

@ -1,22 +1,44 @@
// TODO: type
// @ts-nocheck
"use strict";
import {h as createElement, VNode} from "vue";
import parseStyle from "./ircmessageparser/parseStyle";
import findChannels from "./ircmessageparser/findChannels";
import {findLinks} from "./ircmessageparser/findLinks";
import findEmoji from "./ircmessageparser/findEmoji";
import findNames from "./ircmessageparser/findNames";
import merge from "./ircmessageparser/merge";
import findChannels, {ChannelPart} from "./ircmessageparser/findChannels";
import {findLinks, LinkPart} from "./ircmessageparser/findLinks";
import findEmoji, {EmojiPart} from "./ircmessageparser/findEmoji";
import findNames, {NamePart} from "./ircmessageparser/findNames";
import merge, {MergedParts, Part} from "./ircmessageparser/merge";
import emojiMap from "./fullnamemap.json";
import LinkPreviewToggle from "../../components/LinkPreviewToggle.vue";
import LinkPreviewFileSize from "../../components/LinkPreviewFileSize.vue";
import InlineChannel from "../../components/InlineChannel.vue";
import Username from "../../components/Username.vue";
import {h as createElement, VNode} from "vue";
import {ClientMessage, ClientNetwork} from "../types";
const emojiModifiersRegex = /[\u{1f3fb}-\u{1f3ff}]|\u{fe0f}/gu;
type Fragment = {
class?: string[];
text?: string;
};
type StyledFragment = Fragment & {
textColor?: string;
bgColor?: string;
hexColor?: string;
hexBgColor?: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
monospace?: boolean;
strikethrough?: boolean;
};
// Create an HTML `span` with styling information for a given fragment
// TODO: remove any
function createFragment(fragment: Record<any, any>) {
function createFragment(fragment: StyledFragment): VNode | string | undefined {
const classes: string[] = [];
if (fragment.bold) {
@ -24,12 +46,10 @@ function createFragment(fragment: Record<any, any>) {
}
if (fragment.textColor !== undefined) {
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
classes.push("irc-fg" + fragment.textColor);
}
if (fragment.bgColor !== undefined) {
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
classes.push("irc-bg" + fragment.bgColor);
}
@ -49,7 +69,14 @@ function createFragment(fragment: Record<any, any>) {
classes.push("irc-monospace");
}
const data = {} as Record<string, any>;
const data: {
class?: string[];
style?: Record<string, string>;
} = {
class: undefined,
style: undefined,
};
let hasData = false;
if (classes.length > 0) {
@ -60,17 +87,15 @@ function createFragment(fragment: Record<any, any>) {
if (fragment.hexColor) {
hasData = true;
data.style = {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
color: `#${fragment.hexColor}`,
};
if (fragment.hexBgColor) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
data.style["background-color"] = `#${fragment.hexBgColor}`;
}
}
return hasData ? createElement("span", data, fragment.text) : (fragment.text as string);
return hasData ? createElement("span", data, fragment.text) : fragment.text;
}
// Transform an IRC message potentially filled with styling control codes, URLs,
@ -83,42 +108,40 @@ function parse(text: string, message?: ClientMessage, network?: ClientNetwork) {
// On the plain text, find channels and URLs, returned as "parts". Parts are
// arrays of objects containing start and end markers, as well as metadata
// depending on what was found (channel or link).
const channelPrefixes = network?.serverOptions?.CHANTYPES || ["#", "&"];
const userModes = network?.serverOptions?.PREFIX.symbols || ["!", "@", "%", "+"];
const channelPrefixes = network ? network.serverOptions.CHANTYPES : ["#", "&"];
const userModes = network
? network.serverOptions.PREFIX?.prefix?.map((pref) => pref.symbol)
: ["!", "@", "%", "+"];
const channelParts = findChannels(cleanText, channelPrefixes, userModes);
const linkParts = findLinks(cleanText);
const emojiParts = findEmoji(cleanText);
// TODO: remove type casting.
const nameParts = findNames(cleanText, message ? (message.users as string[]) || [] : []);
const nameParts = findNames(cleanText, message ? message.users || [] : []);
const parts = [...channelParts, ...linkParts, ...emojiParts, ...nameParts];
const parts = (channelParts as MergedParts)
.concat(linkParts)
.concat(emojiParts)
.concat(nameParts);
// Merge the styling information with the channels / URLs / nicks / text objects and
// generate HTML strings with the resulting fragments
return merge(parts, styleFragments, cleanText).map((textPart) => {
const fragments = textPart.fragments?.map((fragment) => createFragment(fragment)) as (
| VNode
| string
)[];
const fragments = textPart.fragments.map((fragment) => createFragment(fragment));
// Wrap these potentially styled fragments with links and channel buttons
// TODO: fix typing
if ("link" in textPart) {
if (textPart.link) {
const preview =
message &&
message.previews &&
// @ts-ignore
message.previews.find((p) => p.link === textPart.link);
const link = createElement(
"a",
{
// @ts-ignore
"^href": textPart.link,
"^dir": preview ? null : "auto",
"^target": "_blank",
"^rel": "noopener",
href: textPart.link,
dir: preview ? null : "auto",
target: "_blank",
rel: "noopener",
},
() => fragments
fragments
);
if (!preview) {
@ -129,22 +152,16 @@ function parse(text: string, message?: ClientMessage, network?: ClientNetwork) {
if (preview.size > 0) {
linkEls.push(
// @ts-ignore
createElement(LinkPreviewFileSize, {
props: {
size: preview.size,
},
size: preview.size,
})
);
}
linkEls.push(
// @ts-ignore
createElement(LinkPreviewToggle, {
props: {
link: preview,
message: message,
},
link: preview,
message: message,
})
);
@ -153,66 +170,49 @@ function parse(text: string, message?: ClientMessage, network?: ClientNetwork) {
return createElement(
"span",
{
attrs: {
dir: "auto",
},
dir: "auto",
},
() => linkEls
linkEls
);
// @ts-ignore
} else if (textPart.channel) {
return createElement(
InlineChannel,
{
props: {
// @ts-ignore
channel: textPart.channel,
},
channel: textPart.channel,
},
() => fragments
{
default: () => fragments,
}
);
// @ts-ignore
} else if (textPart.emoji) {
// @ts-ignore
const emojiWithoutModifiers = textPart.emoji.replace(emojiModifiersRegex, "");
const title = emojiMap[emojiWithoutModifiers]
? `Emoji: ${emojiMap[emojiWithoutModifiers] as string}`
? // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Emoji: ${emojiMap[emojiWithoutModifiers]}`
: null;
return createElement(
"span",
{
class: ["emoji"],
attrs: {
role: "img",
"aria-label": title,
title: title,
},
role: "img",
"aria-label": title,
title: title,
},
() => fragments
fragments
);
// @ts-ignore
} else if (textPart.nick) {
return createElement(
// @ts-ignore
Username,
{
props: {
user: {
// @ts-ignore
nick: textPart.nick,
},
// @ts-ignore
channel: textPart.channel,
network,
},
attrs: {
dir: "auto",
user: {
nick: textPart.nick,
},
dir: "auto",
},
() => fragments
{
default: () => fragments,
}
);
}

View file

@ -27,8 +27,9 @@ type ClientUser = User & {
//
};
type ClientMessage = Message & {
type ClientMessage = Omit<Message, "users"> & {
time: number;
users: string[];
};
type ClientChan = Omit<Chan, "users" | "messages"> & {

View file

@ -273,12 +273,16 @@ class Uploader {
const fullURL = new URL(url, location.toString()).toString();
const textbox = document.getElementById("input");
if (!textbox) {
if (!(textbox instanceof HTMLTextAreaElement)) {
throw new Error("Could not find textbox in upload");
}
const initStart = textbox.selectionStart;
if (!initStart) {
throw new Error("Could not find selection start in textbox in upload");
}
// Get the text before the cursor, and add a space if it's not in the beginning
const headToCursor = initStart > 0 ? textbox.value.substr(0, initStart) + " " : "";

View file

@ -26,7 +26,7 @@ socket.once("push:issubscribed", function (hasSubscriptionOnServer) {
// If client has push registration but the server knows nothing about it,
// this subscription is broken and client has to register again
if (subscription && hasSubscriptionOnServer === false) {
subscription.unsubscribe().then((successful) => {
void subscription.unsubscribe().then((successful) => {
store.commit(
"pushNotificationState",
successful ? "supported" : "unsupported"

View file

@ -15,7 +15,7 @@ import inputs from "./plugins/inputs";
import PublicClient from "./plugins/packages/publicClient";
import SqliteMessageStorage from "./plugins/messageStorage/sqlite";
import TextFileMessageStorage from "./plugins/messageStorage/text";
import Network, {NetworkWithIrcFramework} from "./models/network";
import Network, {IgnoreListItem, NetworkWithIrcFramework} from "./models/network";
import ClientManager from "./clientManager";
import {MessageStorage, SearchQuery, SearchResponse} from "./plugins/messageStorage/types";
@ -219,8 +219,7 @@ class Client {
let network: Network | null = null;
let chan: Chan | null | undefined = null;
for (const i in this.networks) {
const n = this.networks[i];
for (const n of this.networks) {
chan = _.find(n.channels, {id: channelId});
if (chan) {
@ -236,7 +235,7 @@ class Client {
return false;
}
connect(args: any, isStartup = false) {
connect(args: Record<string, any>, isStartup = false) {
const client = this;
const channels: Chan[] = [];
@ -267,19 +266,20 @@ class Client {
"User '" +
client.name +
"' on network '" +
args.name +
(args.name as string) +
"' has an invalid channel which has been ignored"
);
}
}
// TODO; better typing for args
const network = new Network({
uuid: args.uuid,
uuid: args.uuid as string,
name: String(
args.name || (Config.values.lockNetwork ? Config.values.defaults.name : "") || ""
),
host: String(args.host || ""),
port: parseInt(args.port, 10),
port: parseInt(args.port as string, 10),
tls: !!args.tls,
userDisconnected: !!args.userDisconnected,
rejectUnauthorized: !!args.rejectUnauthorized,
@ -291,9 +291,9 @@ class Client {
sasl: String(args.sasl || ""),
saslAccount: String(args.saslAccount || ""),
saslPassword: String(args.saslPassword || ""),
commands: args.commands || [],
commands: (args.commands as string[]) || [],
channels: channels,
ignoreList: args.ignoreList ? args.ignoreList : [],
ignoreList: args.ignoreList ? (args.ignoreList as IgnoreListItem[]) : [],
proxyEnabled: !!args.proxyEnabled,
proxyHost: String(args.proxyHost || ""),
@ -316,6 +316,8 @@ class Client {
(network as NetworkWithIrcFramework).createIrcFramework(client);
// TODO
// eslint-disable-next-line @typescript-eslint/no-misused-promises
events.forEach(async (plugin) => {
(await import(`./plugins/irc-events/${plugin}`)).default.apply(client, [
network.irc,
@ -363,7 +365,7 @@ class Client {
let friendlyAgent = "";
if (agent.browser.name) {
friendlyAgent = `${agent.browser.name} ${agent.browser.major}`;
friendlyAgent = `${agent.browser.name} ${agent.browser.major || ""}`;
} else {
friendlyAgent = "Unknown browser";
}
@ -421,7 +423,7 @@ class Client {
// so that reloading the page will open this channel
this.lastActiveChannel = target.chan.id;
let text = data.text;
let text: string = data.text;
// This is either a normal message or a command escaped with a leading '/'
if (text.charAt(0) !== "/" || text.charAt(1) === "/") {
@ -438,11 +440,11 @@ class Client {
text = "say " + text.replace(/^\//, "");
} else {
text = text.substr(1);
text = text.substring(1);
}
const args = text.split(" ");
const cmd = args.shift().toLowerCase();
const cmd = args?.shift()?.toLowerCase() || "";
const irc = target.network.irc;
let connected = irc && irc.connection && irc.connection.connected;

View file

@ -30,6 +30,9 @@ export type LinkPreview = {
shown: boolean | null;
error: undefined | string;
message: undefined | string;
media: string;
mediaType: string;
};
export default function (client: Client, chan: Chan, msg: Msg, cleanText: string) {
@ -65,6 +68,8 @@ export default function (client: Client, chan: Chan, msg: Msg, cleanText: string
shown: null,
error: undefined,
message: undefined,
media: "",
mediaType: "",
};
cleanLinks.push(preview);
@ -88,11 +93,13 @@ export default function (client: Client, chan: Chan, msg: Msg, cleanText: string
}
function parseHtml(preview, res, client: Client) {
return new Promise((resolve) => {
// TODO:
// eslint-disable-next-line @typescript-eslint/no-misused-promises
return new Promise((resolve: (preview: LinkPreview | null) => void) => {
const $ = cheerio.load(res.data);
return parseHtmlMedia($, preview, client)
.then((newRes) => resolve(newRes))
.then((newRes) => resolve(newRes as any))
.catch(() => {
preview.type = "link";
preview.head =
@ -140,6 +147,8 @@ function parseHtml(preview, res, client: Client) {
preview.thumbActualUrl = thumb;
}
// TODO
// @ts-ignore
resolve(resThumb);
})
.catch(() => resolve(null));
@ -466,7 +475,10 @@ function fetch(uri: string, headers: Record<string, string>) {
})
.on("error", (e) => reject(e))
.on("data", (data) => {
buffer = Buffer.concat([buffer, data], buffer.length + data.length);
buffer = Buffer.concat(
[buffer, data],
buffer.length + (data as Array<any>).length
);
if (buffer.length >= limit) {
gotStream.destroy();
@ -474,7 +486,7 @@ function fetch(uri: string, headers: Record<string, string>) {
})
.on("end", () => gotStream.destroy())
.on("close", () => {
let type: string = "";
let type = "";
// If we downloaded more data then specified in Content-Length, use real data size
const size = contentLength > buffer.length ? contentLength : buffer.length;