mirror of
https://github.com/thelounge/thelounge.git
synced 2024-06-07 08:12:19 +02:00
progress
This commit is contained in:
parent
f37d82dd19
commit
52c13f49c1
|
@ -29,10 +29,36 @@ import ImageViewer from "./ImageViewer.vue";
|
||||||
import ContextMenu from "./ContextMenu.vue";
|
import ContextMenu from "./ContextMenu.vue";
|
||||||
import ConfirmDialog from "./ConfirmDialog.vue";
|
import ConfirmDialog from "./ConfirmDialog.vue";
|
||||||
import Mentions from "./Mentions.vue";
|
import Mentions from "./Mentions.vue";
|
||||||
import {computed, defineComponent, onBeforeUnmount, onMounted, ref} from "vue";
|
import {
|
||||||
|
computed,
|
||||||
|
provide,
|
||||||
|
defineComponent,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
Ref,
|
||||||
|
InjectionKey,
|
||||||
|
inject,
|
||||||
|
} from "vue";
|
||||||
import {useStore} from "../js/store";
|
import {useStore} from "../js/store";
|
||||||
import type {DebouncedFunc} from "lodash";
|
import type {DebouncedFunc} from "lodash";
|
||||||
|
|
||||||
|
const imageViewerKey = Symbol() as InjectionKey<Ref<typeof ImageViewer | null>>;
|
||||||
|
const contextMenuKey = Symbol() as InjectionKey<Ref<typeof ContextMenu | null>>;
|
||||||
|
const confirmDialogKey = Symbol() as InjectionKey<Ref<typeof ConfirmDialog | null>>;
|
||||||
|
|
||||||
|
export const useImageViewer = () => {
|
||||||
|
return inject(imageViewerKey) as Ref<typeof ImageViewer | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useContextMenu = () => {
|
||||||
|
return inject(contextMenuKey) as Ref<typeof ContextMenu | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConfirmDialog = () => {
|
||||||
|
return inject(confirmDialogKey) as Ref<typeof ConfirmDialog | null>;
|
||||||
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "App",
|
name: "App",
|
||||||
components: {
|
components: {
|
||||||
|
@ -51,6 +77,10 @@ export default defineComponent({
|
||||||
const contextMenu = ref(null);
|
const contextMenu = ref(null);
|
||||||
const confirmDialog = ref(null);
|
const confirmDialog = ref(null);
|
||||||
|
|
||||||
|
provide(imageViewerKey, imageViewer);
|
||||||
|
provide(contextMenuKey, contextMenu);
|
||||||
|
provide(confirmDialogKey, confirmDialog);
|
||||||
|
|
||||||
const viewportClasses = computed(() => {
|
const viewportClasses = computed(() => {
|
||||||
return {
|
return {
|
||||||
notified: store.getters.highlightCount > 0,
|
notified: store.getters.highlightCount > 0,
|
||||||
|
|
|
@ -3,10 +3,10 @@
|
||||||
<div
|
<div
|
||||||
id="chat"
|
id="chat"
|
||||||
:class="{
|
:class="{
|
||||||
'hide-motd': !$store.state.settings.motd,
|
'hide-motd': store.state.settings.motd,
|
||||||
'colored-nicks': $store.state.settings.coloredNicks,
|
'colored-nicks': store.state.settings.coloredNicks,
|
||||||
'time-seconds': $store.state.settings.showSeconds,
|
'time-seconds': store.state.settings.showSeconds,
|
||||||
'time-12h': $store.state.settings.use12hClock,
|
'time-12h': store.state.settings.use12hClock,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -47,7 +47,7 @@
|
||||||
/></span>
|
/></span>
|
||||||
<MessageSearchForm
|
<MessageSearchForm
|
||||||
v-if="
|
v-if="
|
||||||
$store.state.settings.searchEnabled &&
|
store.state.settings.searchEnabled &&
|
||||||
['channel', 'query'].includes(channel.type)
|
['channel', 'query'].includes(channel.type)
|
||||||
"
|
"
|
||||||
:network="network"
|
:network="network"
|
||||||
|
@ -71,7 +71,7 @@
|
||||||
<button
|
<button
|
||||||
class="rt"
|
class="rt"
|
||||||
aria-label="Toggle user list"
|
aria-label="Toggle user list"
|
||||||
@click="$store.commit('toggleUserlist')"
|
@click="store.commit('toggleUserlist')"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -95,7 +95,7 @@
|
||||||
{'scroll-down-shown': !channel.scrolledToBottom},
|
{'scroll-down-shown': !channel.scrolledToBottom},
|
||||||
]"
|
]"
|
||||||
aria-label="Jump to recent messages"
|
aria-label="Jump to recent messages"
|
||||||
@click="$refs.messageList.jumpToBottom()"
|
@click="messageList?.jumpToBottom()"
|
||||||
>
|
>
|
||||||
<div class="scroll-down-arrow" />
|
<div class="scroll-down-arrow" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -110,11 +110,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.currentUserVisibleError"
|
v-if="store.state.currentUserVisibleError"
|
||||||
id="user-visible-error"
|
id="user-visible-error"
|
||||||
@click="hideUserVisibleError"
|
@click="hideUserVisibleError"
|
||||||
>
|
>
|
||||||
{{ $store.state.currentUserVisibleError }}
|
{{ store.state.currentUserVisibleError }}
|
||||||
</div>
|
</div>
|
||||||
<ChatInput :network="network" :channel="channel" />
|
<ChatInput :network="network" :channel="channel" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -133,8 +133,9 @@ import ListBans from "./Special/ListBans.vue";
|
||||||
import ListInvites from "./Special/ListInvites.vue";
|
import ListInvites from "./Special/ListInvites.vue";
|
||||||
import ListChannels from "./Special/ListChannels.vue";
|
import ListChannels from "./Special/ListChannels.vue";
|
||||||
import ListIgnored from "./Special/ListIgnored.vue";
|
import ListIgnored from "./Special/ListIgnored.vue";
|
||||||
import {Component, defineComponent, PropType} from "vue";
|
import {defineComponent, PropType, ref, computed, watch, nextTick, onMounted, Component} from "vue";
|
||||||
import type {ClientNetwork, ClientChan} from "../js/types";
|
import type {ClientNetwork, ClientChan} from "../js/types";
|
||||||
|
import {useStore} from "../js/store";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "Chat",
|
name: "Chat",
|
||||||
|
@ -151,92 +152,123 @@ export default defineComponent({
|
||||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||||
focused: String,
|
focused: String,
|
||||||
},
|
},
|
||||||
computed: {
|
setup(props) {
|
||||||
specialComponent(): Component {
|
const store = useStore();
|
||||||
switch (this.channel.special) {
|
|
||||||
|
const messageList = ref<typeof MessageList>();
|
||||||
|
const topicInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const specialComponent = computed(() => {
|
||||||
|
switch (props.channel.special) {
|
||||||
case "list_bans":
|
case "list_bans":
|
||||||
return ListBans;
|
return ListBans as Component;
|
||||||
case "list_invites":
|
case "list_invites":
|
||||||
return ListInvites;
|
return ListInvites as Component;
|
||||||
case "list_channels":
|
case "list_channels":
|
||||||
return ListChannels;
|
return ListChannels as Component;
|
||||||
case "list_ignored":
|
case "list_ignored":
|
||||||
return ListIgnored;
|
return ListIgnored as Component;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
});
|
||||||
},
|
|
||||||
watch: {
|
const channelChanged = () => {
|
||||||
channel() {
|
// Triggered when active channel is set or changed
|
||||||
this.channelChanged();
|
props.channel.highlight = 0;
|
||||||
},
|
props.channel.unread = 0;
|
||||||
"channel.editTopic"(newValue) {
|
|
||||||
if (newValue) {
|
socket.emit("open", props.channel.id);
|
||||||
this.$nextTick(() => {
|
|
||||||
this.$refs.topicInput.focus();
|
if (props.channel.usersOutdated) {
|
||||||
|
props.channel.usersOutdated = false;
|
||||||
|
|
||||||
|
socket.emit("names", {
|
||||||
|
target: props.channel.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideUserVisibleError = () => {
|
||||||
|
store.commit("currentUserVisibleError", null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editTopic = () => {
|
||||||
|
if (props.channel.type === "channel") {
|
||||||
|
props.channel.editTopic = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTopic = () => {
|
||||||
|
props.channel.editTopic = false;
|
||||||
|
|
||||||
|
if (!topicInput.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTopic = topicInput.value.value;
|
||||||
|
|
||||||
|
if (props.channel.topic !== newTopic) {
|
||||||
|
const target = props.channel.id;
|
||||||
|
const text = `/raw TOPIC ${props.channel.name} :${newTopic}`;
|
||||||
|
socket.emit("input", {target, text});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openContextMenu = (event: any) => {
|
||||||
|
eventbus.emit("contextmenu:channel", {
|
||||||
|
event: event,
|
||||||
|
channel: props.channel,
|
||||||
|
network: props.network,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMentions = (event: any) => {
|
||||||
|
eventbus.emit("mentions:toggle", {
|
||||||
|
event: event,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(props.channel, () => {
|
||||||
|
channelChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editTopicRef = ref(props.channel.editTopic);
|
||||||
|
watch(editTopicRef, (newTopic) => {
|
||||||
|
if (newTopic) {
|
||||||
|
nextTick(() => {
|
||||||
|
topicInput.value?.focus();
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error(e);
|
console.error(e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.channelChanged();
|
|
||||||
|
|
||||||
if (this.channel.editTopic) {
|
onMounted(() => {
|
||||||
this.$nextTick(() => {
|
channelChanged();
|
||||||
this.$refs.topicInput.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
channelChanged() {
|
|
||||||
// Triggered when active channel is set or changed
|
|
||||||
this.channel.highlight = 0;
|
|
||||||
this.channel.unread = 0;
|
|
||||||
|
|
||||||
socket.emit("open", this.channel.id);
|
if (props.channel.editTopic) {
|
||||||
|
nextTick(() => {
|
||||||
if (this.channel.usersOutdated) {
|
topicInput.value?.focus();
|
||||||
this.channel.usersOutdated = false;
|
}).catch(() => {
|
||||||
|
// no-op
|
||||||
socket.emit("names", {
|
|
||||||
target: this.channel.id,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
hideUserVisibleError() {
|
|
||||||
this.$store.commit("currentUserVisibleError", null);
|
|
||||||
},
|
|
||||||
editTopic() {
|
|
||||||
if (this.channel.type === "channel") {
|
|
||||||
this.channel.editTopic = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
saveTopic() {
|
|
||||||
this.channel.editTopic = false;
|
|
||||||
const newTopic = this.$refs.topicInput.value;
|
|
||||||
|
|
||||||
if (this.channel.topic !== newTopic) {
|
return {
|
||||||
const target = this.channel.id;
|
store,
|
||||||
const text = `/raw TOPIC ${this.channel.name} :${newTopic}`;
|
messageList,
|
||||||
socket.emit("input", {target, text});
|
topicInput,
|
||||||
}
|
specialComponent,
|
||||||
},
|
editTopicRef,
|
||||||
openContextMenu(event) {
|
hideUserVisibleError,
|
||||||
eventbus.emit("contextmenu:channel", {
|
editTopic,
|
||||||
event: event,
|
saveTopic,
|
||||||
channel: this.channel,
|
openContextMenu,
|
||||||
network: this.network,
|
openMentions,
|
||||||
});
|
};
|
||||||
},
|
|
||||||
openMentions(event) {
|
|
||||||
eventbus.emit("mentions:toggle", {
|
|
||||||
event: event,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="$store.state.serverConfiguration?.fileUpload"
|
v-if="store.state.serverConfiguration?.fileUpload"
|
||||||
id="upload-tooltip"
|
id="upload-tooltip"
|
||||||
class="tooltipped tooltipped-w tooltipped-no-touch"
|
class="tooltipped tooltipped-w tooltipped-no-touch"
|
||||||
aria-label="Upload file"
|
aria-label="Upload file"
|
||||||
|
@ -34,7 +34,7 @@
|
||||||
id="upload"
|
id="upload"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Upload file"
|
aria-label="Upload file"
|
||||||
:disabled="!$store.state.isConnected"
|
:disabled="!store.state.isConnected"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
@ -46,7 +46,7 @@
|
||||||
id="submit"
|
id="submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
:disabled="!$store.state.isConnected"
|
:disabled="!store.state.isConnected"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</form>
|
</form>
|
||||||
|
@ -60,8 +60,9 @@ import commands from "../js/commands/index";
|
||||||
import socket from "../js/socket";
|
import socket from "../js/socket";
|
||||||
import upload from "../js/upload";
|
import upload from "../js/upload";
|
||||||
import eventbus from "../js/eventbus";
|
import eventbus from "../js/eventbus";
|
||||||
import {defineComponent, PropType} from "vue";
|
import {watch, defineComponent, nextTick, onMounted, PropType, ref, onUnmounted} from "vue";
|
||||||
import type {ClientNetwork, ClientChan} from "../js/types";
|
import type {ClientNetwork, ClientChan} from "../js/types";
|
||||||
|
import {useStore} from "../js/store";
|
||||||
|
|
||||||
const formattingHotkeys = {
|
const formattingHotkeys = {
|
||||||
"mod+k": "\x03",
|
"mod+k": "\x03",
|
||||||
|
@ -88,178 +89,103 @@ const bracketWraps = {
|
||||||
_: "_",
|
_: "_",
|
||||||
};
|
};
|
||||||
|
|
||||||
let autocompletionRef = null;
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "ChatInput",
|
name: "ChatInput",
|
||||||
props: {
|
props: {
|
||||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||||
},
|
},
|
||||||
watch: {
|
setup(props) {
|
||||||
"channel.id"() {
|
const store = useStore();
|
||||||
if (autocompletionRef) {
|
const input = ref<HTMLTextAreaElement>();
|
||||||
autocompletionRef.hide();
|
const uploadInput = ref<HTMLInputElement>();
|
||||||
}
|
const autocompletionRef = ref<ReturnType<typeof autocompletion>>();
|
||||||
},
|
|
||||||
"channel.pendingMessage"() {
|
|
||||||
this.setInputSize();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
eventbus.on("escapekey", this.blurInput);
|
|
||||||
|
|
||||||
if (this.$accessor.settings.autocomplete) {
|
const setInputSize = () => {
|
||||||
autocompletionRef = autocompletion(this.$refs.input);
|
nextTick(() => {
|
||||||
}
|
if (!input.value) {
|
||||||
|
|
||||||
const inputTrap = Mousetrap(this.$refs.input);
|
|
||||||
|
|
||||||
inputTrap.bind(Object.keys(formattingHotkeys), function (e, key) {
|
|
||||||
const modifier = formattingHotkeys[key];
|
|
||||||
|
|
||||||
wrapCursor(
|
|
||||||
e.target,
|
|
||||||
modifier,
|
|
||||||
e.target.selectionStart === e.target.selectionEnd ? "" : modifier
|
|
||||||
);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
inputTrap.bind(Object.keys(bracketWraps), function (e, key) {
|
|
||||||
if (e.target?.selectionStart !== e.target.selectionEnd) {
|
|
||||||
wrapCursor(e.target, key, bracketWraps[key]);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
inputTrap.bind(["up", "down"], (e, key) => {
|
|
||||||
if (
|
|
||||||
this.$accessor.isAutoCompleting ||
|
|
||||||
e.target.selectionStart !== e.target.selectionEnd
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onRow = (
|
|
||||||
this.$refs.input.value.slice(null, this.$refs.input.selectionStart).match(/\n/g) ||
|
|
||||||
[]
|
|
||||||
).length;
|
|
||||||
const totalRows = (this.$refs.input.value.match(/\n/g) || []).length;
|
|
||||||
|
|
||||||
const {channel} = this;
|
|
||||||
|
|
||||||
if (channel.inputHistoryPosition === 0) {
|
|
||||||
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === "up" && onRow === 0) {
|
|
||||||
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
|
|
||||||
channel.inputHistoryPosition++;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (key === "down" && channel.inputHistoryPosition > 0 && onRow === totalRows) {
|
|
||||||
channel.inputHistoryPosition--;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
|
|
||||||
this.$refs.input.value = channel.pendingMessage;
|
|
||||||
this.setInputSize();
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.$accessor.serverConfiguration.fileUpload) {
|
|
||||||
upload.mounted();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
unmounted() {
|
|
||||||
eventbus.off("escapekey", this.blurInput);
|
|
||||||
|
|
||||||
if (autocompletionRef) {
|
|
||||||
autocompletionRef.destroy();
|
|
||||||
autocompletionRef = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
upload.abort();
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
setPendingMessage(e) {
|
|
||||||
this.channel.pendingMessage = e.target.value;
|
|
||||||
this.channel.inputHistoryPosition = 0;
|
|
||||||
this.setInputSize();
|
|
||||||
},
|
|
||||||
setInputSize() {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (!this.$refs.input) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const style = window.getComputedStyle(this.$refs.input);
|
const style = window.getComputedStyle(input.value);
|
||||||
const lineHeight = parseFloat(style.lineHeight, 10) || 1;
|
const lineHeight = parseFloat(style.lineHeight) || 1;
|
||||||
|
|
||||||
// Start by resetting height before computing as scrollHeight does not
|
// Start by resetting height before computing as scrollHeight does not
|
||||||
// decrease when deleting characters
|
// decrease when deleting characters
|
||||||
this.$refs.input.style.height = "";
|
input.value.style.height = "";
|
||||||
|
|
||||||
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
|
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
|
||||||
// because some browsers tend to incorrently round the values when using high density
|
// because some browsers tend to incorrently round the values when using high density
|
||||||
// displays or using page zoom feature
|
// displays or using page zoom feature
|
||||||
this.$refs.input.style.height =
|
input.value.style.height = `${
|
||||||
Math.ceil(this.$refs.input.scrollHeight / lineHeight) * lineHeight + "px";
|
Math.ceil(input.value.scrollHeight / lineHeight) * lineHeight
|
||||||
|
}px`;
|
||||||
|
}).catch(() => {
|
||||||
|
// no-op
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
getInputPlaceholder(channel) {
|
|
||||||
|
const setPendingMessage = (e: Event) => {
|
||||||
|
props.channel.pendingMessage = (e.target as HTMLInputElement).value;
|
||||||
|
props.channel.inputHistoryPosition = 0;
|
||||||
|
setInputSize();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInputPlaceholder = (channel: ClientChan) => {
|
||||||
if (channel.type === "channel" || channel.type === "query") {
|
if (channel.type === "channel" || channel.type === "query") {
|
||||||
return `Write to ${channel.name}`;
|
return `Write to ${channel.name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
},
|
};
|
||||||
onSubmit() {
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
if (!input.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Triggering click event opens the virtual keyboard on mobile
|
// Triggering click event opens the virtual keyboard on mobile
|
||||||
// This can only be called from another interactive event (e.g. button click)
|
// This can only be called from another interactive event (e.g. button click)
|
||||||
this.$refs.input.click();
|
input.value.click();
|
||||||
this.$refs.input.focus();
|
input.value.focus();
|
||||||
|
|
||||||
if (!this.$accessor.isConnected) {
|
if (!store.state.isConnected) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = this.channel.id;
|
const target = props.channel.id;
|
||||||
const text = this.channel.pendingMessage;
|
const text = props.channel.pendingMessage;
|
||||||
|
|
||||||
if (text.length === 0) {
|
if (text.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autocompletionRef) {
|
if (autocompletionRef.value) {
|
||||||
autocompletionRef.hide();
|
autocompletionRef.value.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.channel.inputHistoryPosition = 0;
|
props.channel.inputHistoryPosition = 0;
|
||||||
this.channel.pendingMessage = "";
|
props.channel.pendingMessage = "";
|
||||||
this.$refs.input.value = "";
|
input.value.value = "";
|
||||||
this.setInputSize();
|
setInputSize();
|
||||||
|
|
||||||
// Store new message in history if last message isn't already equal
|
// Store new message in history if last message isn't already equal
|
||||||
if (this.channel.inputHistory[1] !== text) {
|
if (props.channel.inputHistory[1] !== text) {
|
||||||
this.channel.inputHistory.splice(1, 0, text);
|
props.channel.inputHistory.splice(1, 0, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit input history to a 100 entries
|
// Limit input history to a 100 entries
|
||||||
if (this.channel.inputHistory.length > 100) {
|
if (props.channel.inputHistory.length > 100) {
|
||||||
this.channel.inputHistory.pop();
|
props.channel.inputHistory.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text[0] === "/") {
|
if (text[0] === "/") {
|
||||||
const args = text.substr(1).split(" ");
|
const args = text.substring(1).split(" ");
|
||||||
const cmd = args.shift().toLowerCase();
|
const cmd = args.shift()?.toLowerCase();
|
||||||
|
|
||||||
|
if (!cmd) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Object.prototype.hasOwnProperty.call(commands, cmd) &&
|
Object.prototype.hasOwnProperty.call(commands, cmd) &&
|
||||||
|
@ -270,23 +196,165 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit("input", {target, text});
|
socket.emit("input", {target, text});
|
||||||
},
|
};
|
||||||
onUploadInputChange() {
|
|
||||||
const files = Array.from(this.$refs.uploadInput.files);
|
const onUploadInputChange = () => {
|
||||||
upload.triggerUpload(files);
|
if (!uploadInput.value || !uploadInput.value.files) {
|
||||||
this.$refs.uploadInput.value = ""; // Reset <input> element so you can upload the same file
|
return;
|
||||||
},
|
|
||||||
openFileUpload() {
|
|
||||||
this.$refs.uploadInput.click();
|
|
||||||
},
|
|
||||||
blurInput() {
|
|
||||||
this.$refs.input.blur();
|
|
||||||
},
|
|
||||||
onBlur() {
|
|
||||||
if (autocompletionRef) {
|
|
||||||
autocompletionRef.hide();
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
const files = Array.from(uploadInput.value.files);
|
||||||
|
upload.triggerUpload(files);
|
||||||
|
uploadInput.value.value = ""; // Reset <input> element so you can upload the same file
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFileUpload = () => {
|
||||||
|
uploadInput.value?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const blurInput = () => {
|
||||||
|
input.value?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlur = () => {
|
||||||
|
if (autocompletionRef.value) {
|
||||||
|
autocompletionRef.value.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const channelId = ref(props.channel.id);
|
||||||
|
watch(channelId, () => {
|
||||||
|
if (autocompletionRef.value) {
|
||||||
|
autocompletionRef.value.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingMessage = ref(props.channel.pendingMessage);
|
||||||
|
watch(pendingMessage, () => {
|
||||||
|
setInputSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
eventbus.on("escapekey", blurInput);
|
||||||
|
|
||||||
|
if (store.state.settings.autocomplete) {
|
||||||
|
if (!input.value) {
|
||||||
|
throw new Error("ChatInput autocomplete: input element is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
autocompletionRef.value = autocompletion(input.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputTrap = Mousetrap(input.value);
|
||||||
|
|
||||||
|
inputTrap.bind(Object.keys(formattingHotkeys), function (e, key) {
|
||||||
|
const modifier = formattingHotkeys[key];
|
||||||
|
|
||||||
|
if (!e.target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO; investigate types
|
||||||
|
wrapCursor(
|
||||||
|
e.target as HTMLTextAreaElement,
|
||||||
|
modifier,
|
||||||
|
(e.target as HTMLTextAreaElement).selectionStart ===
|
||||||
|
(e.target as HTMLTextAreaElement).selectionEnd
|
||||||
|
? ""
|
||||||
|
: modifier
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
inputTrap.bind(Object.keys(bracketWraps), function (e, key) {
|
||||||
|
if (
|
||||||
|
(e.target as HTMLTextAreaElement)?.selectionStart !==
|
||||||
|
(e.target as HTMLTextAreaElement).selectionEnd
|
||||||
|
) {
|
||||||
|
wrapCursor(e.target as HTMLTextAreaElement, key, bracketWraps[key]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inputTrap.bind(["up", "down"], (e, key) => {
|
||||||
|
if (
|
||||||
|
store.state.isAutoCompleting ||
|
||||||
|
(e.target as HTMLTextAreaElement).selectionStart !==
|
||||||
|
(e.target as HTMLTextAreaElement).selectionEnd ||
|
||||||
|
!input.value
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRow = (
|
||||||
|
input.value.value.slice(undefined, input.value.selectionStart).match(/\n/g) ||
|
||||||
|
[]
|
||||||
|
).length;
|
||||||
|
const totalRows = (input.value.value.match(/\n/g) || []).length;
|
||||||
|
|
||||||
|
const {channel} = props;
|
||||||
|
|
||||||
|
if (channel.inputHistoryPosition === 0) {
|
||||||
|
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "up" && onRow === 0) {
|
||||||
|
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
|
||||||
|
channel.inputHistoryPosition++;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
key === "down" &&
|
||||||
|
channel.inputHistoryPosition > 0 &&
|
||||||
|
onRow === totalRows
|
||||||
|
) {
|
||||||
|
channel.inputHistoryPosition--;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
|
||||||
|
input.value.value = channel.pendingMessage;
|
||||||
|
setInputSize();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (store.state.serverConfiguration?.fileUpload) {
|
||||||
|
upload.mounted();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
eventbus.off("escapekey", blurInput);
|
||||||
|
|
||||||
|
if (autocompletionRef.value) {
|
||||||
|
autocompletionRef.value.destroy();
|
||||||
|
autocompletionRef.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
upload.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
input,
|
||||||
|
uploadInput,
|
||||||
|
onUploadInputChange,
|
||||||
|
openFileUpload,
|
||||||
|
blurInput,
|
||||||
|
onBlur,
|
||||||
|
channelId,
|
||||||
|
pendingMessage,
|
||||||
|
setInputSize,
|
||||||
|
upload,
|
||||||
|
getInputPlaceholder,
|
||||||
|
onSubmit,
|
||||||
|
setPendingMessage,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -33,7 +33,7 @@ export default defineComponent({
|
||||||
const localeDate = computed(() => dayjs(props.message.time).format("D MMMM YYYY"));
|
const localeDate = computed(() => dayjs(props.message.time).format("D MMMM YYYY"));
|
||||||
|
|
||||||
const hoursPassed = () => {
|
const hoursPassed = () => {
|
||||||
return (Date.now() - Date.parse(props.message.time?.toISOString())) / 3600000;
|
return (Date.now() - Date.parse(props.message.time.toString())) / 3600000;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dayChange = () => {
|
const dayChange = () => {
|
||||||
|
|
|
@ -42,7 +42,7 @@
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import {computed, defineComponent, ref, watch} from "vue";
|
import {computed, defineComponent, ref, watch} from "vue";
|
||||||
import eventbus from "../js/eventbus";
|
import eventbus from "../js/eventbus";
|
||||||
import {ClientChan, ClientMessage, LinkPreview} from "../js/types";
|
import {ClientChan, ClientMessage, ClientLinkPreview} from "../js/types";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "ImageViewer",
|
name: "ImageViewer",
|
||||||
|
@ -50,9 +50,9 @@ export default defineComponent({
|
||||||
const viewer = ref<HTMLDivElement>();
|
const viewer = ref<HTMLDivElement>();
|
||||||
const image = ref<HTMLImageElement>();
|
const image = ref<HTMLImageElement>();
|
||||||
|
|
||||||
const link = ref<LinkPreview | null>(null);
|
const link = ref<ClientLinkPreview | null>(null);
|
||||||
const previousImage = ref<LinkPreview | null>();
|
const previousImage = ref<ClientLinkPreview | null>();
|
||||||
const nextImage = ref<LinkPreview | null>();
|
const nextImage = ref<ClientLinkPreview | null>();
|
||||||
const channel = ref<ClientChan | null>();
|
const channel = ref<ClientChan | null>();
|
||||||
|
|
||||||
const position = ref<{
|
const position = ref<{
|
||||||
|
@ -98,7 +98,7 @@ export default defineComponent({
|
||||||
};
|
};
|
||||||
|
|
||||||
const setPrevNextImages = () => {
|
const setPrevNextImages = () => {
|
||||||
if (!channel.value) {
|
if (!channel.value || !link.value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,7 +107,7 @@ export default defineComponent({
|
||||||
.flat()
|
.flat()
|
||||||
.filter((preview) => preview.thumb);
|
.filter((preview) => preview.thumb);
|
||||||
|
|
||||||
const currentIndex = links.indexOf(this.link);
|
const currentIndex = links.indexOf(link.value);
|
||||||
|
|
||||||
previousImage.value = links[currentIndex - 1] || null;
|
previousImage.value = links[currentIndex - 1] || null;
|
||||||
nextImage.value = links[currentIndex + 1] || null;
|
nextImage.value = links[currentIndex + 1] || null;
|
||||||
|
@ -125,10 +125,6 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onImageLoad = () => {
|
|
||||||
prepareImage();
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepareImage = () => {
|
const prepareImage = () => {
|
||||||
const viewerEl = viewer.value;
|
const viewerEl = viewer.value;
|
||||||
const imageEl = image.value;
|
const imageEl = image.value;
|
||||||
|
@ -148,6 +144,10 @@ export default defineComponent({
|
||||||
transform.value.y = height / 2;
|
transform.value.y = height / 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onImageLoad = () => {
|
||||||
|
prepareImage();
|
||||||
|
};
|
||||||
|
|
||||||
const calculateZoomShift = (newScale: number, x: number, y: number, oldScale: number) => {
|
const calculateZoomShift = (newScale: number, x: number, y: number, oldScale: number) => {
|
||||||
if (!image.value || !viewer.value) {
|
if (!image.value || !viewer.value) {
|
||||||
return;
|
return;
|
||||||
|
@ -241,7 +241,7 @@ export default defineComponent({
|
||||||
// 1. Move around by dragging it with one finger
|
// 1. Move around by dragging it with one finger
|
||||||
// 2. Change image scale by using two fingers
|
// 2. Change image scale by using two fingers
|
||||||
const onImageTouchStart = (e: TouchEvent) => {
|
const onImageTouchStart = (e: TouchEvent) => {
|
||||||
const image = this.$refs.image;
|
const img = image.value;
|
||||||
let touch = reduceTouches(e.touches);
|
let touch = reduceTouches(e.touches);
|
||||||
let currentTouches = e.touches;
|
let currentTouches = e.touches;
|
||||||
let touchEndFingers = 0;
|
let touchEndFingers = 0;
|
||||||
|
@ -313,12 +313,12 @@ export default defineComponent({
|
||||||
|
|
||||||
correctPosition();
|
correctPosition();
|
||||||
|
|
||||||
image.removeEventListener("touchmove", touchMove, {passive: true});
|
img?.removeEventListener("touchmove", touchMove);
|
||||||
image.removeEventListener("touchend", touchEnd, {passive: true});
|
img?.removeEventListener("touchend", touchEnd);
|
||||||
};
|
};
|
||||||
|
|
||||||
image.addEventListener("touchmove", touchMove, {passive: true});
|
img?.addEventListener("touchmove", touchMove, {passive: true});
|
||||||
image.addEventListener("touchend", touchEnd, {passive: true});
|
img?.addEventListener("touchend", touchEnd, {passive: true});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Image mouse manipulation:
|
// Image mouse manipulation:
|
||||||
|
|
|
@ -130,144 +130,177 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {defineComponent, PropType} from "vue";
|
import Vue, {
|
||||||
|
computed,
|
||||||
|
defineComponent,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
PropType,
|
||||||
|
ref,
|
||||||
|
inject,
|
||||||
|
Ref,
|
||||||
|
watch,
|
||||||
|
} from "vue";
|
||||||
import eventbus from "../js/eventbus";
|
import eventbus from "../js/eventbus";
|
||||||
import friendlysize from "../js/helpers/friendlysize";
|
import friendlysize from "../js/helpers/friendlysize";
|
||||||
import type {ClientChan} from "../js/types";
|
import {useStore} from "../js/store";
|
||||||
|
import type {ClientChan, ClientLinkPreview} from "../js/types";
|
||||||
|
import {useImageViewer} from "./App.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "LinkPreview",
|
name: "LinkPreview",
|
||||||
props: {
|
props: {
|
||||||
link: {
|
link: {
|
||||||
type: Object,
|
type: Object as PropType<ClientLinkPreview>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
keepScrollPosition: {
|
||||||
|
type: Function as PropType<() => void>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
keepScrollPosition: Function,
|
|
||||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||||
},
|
},
|
||||||
data() {
|
setup(props) {
|
||||||
return {
|
const store = useStore();
|
||||||
showMoreButton: false,
|
|
||||||
isContentShown: false,
|
const showMoreButton = ref(false);
|
||||||
};
|
const isContentShown = ref(false);
|
||||||
},
|
|
||||||
computed: {
|
const content = ref<HTMLDivElement | null>(null);
|
||||||
moreButtonLabel(): string {
|
const container = ref<HTMLDivElement | null>(null);
|
||||||
return this.isContentShown ? "Less" : "More";
|
|
||||||
},
|
const moreButtonLabel = computed(() => {
|
||||||
imageMaxSize(): string | undefined {
|
return isContentShown.value ? "Less" : "More";
|
||||||
if (!this.link.maxSize) {
|
});
|
||||||
|
|
||||||
|
// TODO: type
|
||||||
|
const imageMaxSize = computed(() => {
|
||||||
|
// @ts-ignore
|
||||||
|
if (!props.link.maxSize) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return friendlysize(this.link.maxSize);
|
// @ts-ignore
|
||||||
},
|
return friendlysize(props.link.maxSize);
|
||||||
},
|
});
|
||||||
watch: {
|
|
||||||
"link.type"() {
|
|
||||||
this.updateShownState();
|
|
||||||
this.onPreviewUpdate();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.updateShownState();
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
eventbus.on("resize", this.handleResize);
|
|
||||||
|
|
||||||
this.onPreviewUpdate();
|
const handleResize = () => {
|
||||||
},
|
nextTick(() => {
|
||||||
beforeUnmount() {
|
if (!content.value || !container.value) {
|
||||||
eventbus.off("resize", this.handleResize);
|
|
||||||
},
|
|
||||||
unmounted() {
|
|
||||||
// Let this preview go through load/canplay events again,
|
|
||||||
// Otherwise the browser can cause a resize on video elements
|
|
||||||
this.link.sourceLoaded = false;
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onPreviewUpdate() {
|
|
||||||
// Don't display previews while they are loading on the server
|
|
||||||
if (this.link.type === "loading") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error does not have any media to render
|
|
||||||
if (this.link.type === "error") {
|
|
||||||
this.onPreviewReady();
|
|
||||||
}
|
|
||||||
|
|
||||||
// If link doesn't have a thumbnail, render it
|
|
||||||
if (this.link.type === "link") {
|
|
||||||
this.handleResize();
|
|
||||||
this.keepScrollPosition();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPreviewReady() {
|
|
||||||
this.$set(this.link, "sourceLoaded", true);
|
|
||||||
|
|
||||||
this.keepScrollPosition();
|
|
||||||
|
|
||||||
if (this.link.type === "link") {
|
|
||||||
this.handleResize();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onThumbnailError() {
|
|
||||||
// If thumbnail fails to load, hide it and show the preview without it
|
|
||||||
this.link.thumb = "";
|
|
||||||
this.onPreviewReady();
|
|
||||||
},
|
|
||||||
onThumbnailClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const imageViewer = this.$root.$refs.app.$refs.imageViewer;
|
|
||||||
imageViewer.channel = this.channel;
|
|
||||||
imageViewer.link = this.link;
|
|
||||||
},
|
|
||||||
onMoreClick() {
|
|
||||||
this.isContentShown = !this.isContentShown;
|
|
||||||
this.keepScrollPosition();
|
|
||||||
},
|
|
||||||
handleResize() {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (!this.$refs.content) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.showMoreButton =
|
showMoreButton.value = content.value.offsetWidth >= container.value.offsetWidth;
|
||||||
this.$refs.content.offsetWidth >= this.$refs.container.offsetWidth;
|
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error("Error in LinkPreview.handleResize", e);
|
console.error("Error in LinkPreview.handleResize", e);
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
updateShownState() {
|
|
||||||
|
const onPreviewReady = () => {
|
||||||
|
props.link.sourceLoaded = true;
|
||||||
|
|
||||||
|
props.keepScrollPosition();
|
||||||
|
|
||||||
|
if (props.link.type === "link") {
|
||||||
|
handleResize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPreviewUpdate = () => {
|
||||||
|
// Don't display previews while they are loading on the server
|
||||||
|
if (props.link.type === "loading") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error does not have any media to render
|
||||||
|
if (props.link.type === "error") {
|
||||||
|
onPreviewReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If link doesn't have a thumbnail, render it
|
||||||
|
if (props.link.type === "link") {
|
||||||
|
handleResize();
|
||||||
|
props.keepScrollPosition();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onThumbnailError = () => {
|
||||||
|
// If thumbnail fails to load, hide it and show the preview without it
|
||||||
|
props.link.thumb = "";
|
||||||
|
onPreviewReady();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onThumbnailClick = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const imageViewer = useImageViewer();
|
||||||
|
|
||||||
|
if (!imageViewer.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageViewer.value.channel = props.channel;
|
||||||
|
imageViewer.value.link = props.link;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMoreClick = () => {
|
||||||
|
isContentShown.value = !isContentShown.value;
|
||||||
|
props.keepScrollPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateShownState = () => {
|
||||||
// User has manually toggled the preview, do not apply default
|
// User has manually toggled the preview, do not apply default
|
||||||
if (this.link.shown !== null) {
|
if (props.link.shown !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let defaultState = false;
|
let defaultState = false;
|
||||||
|
|
||||||
switch (this.link.type) {
|
switch (props.link.type) {
|
||||||
case "error":
|
case "error":
|
||||||
// Collapse all errors by default unless its a message about image being too big
|
// Collapse all errors by default unless its a message about image being too big
|
||||||
if (this.link.error === "image-too-big") {
|
if (props.link.error === "image-too-big") {
|
||||||
defaultState = this.$accessor.settings.media;
|
defaultState = store.state.settings.media;
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "link":
|
case "link":
|
||||||
defaultState = this.$accessor.settings.links;
|
defaultState = store.state.settings.links;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
defaultState = this.$accessor.settings.media;
|
defaultState = store.state.settings.media;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.link.shown = defaultState;
|
props.link.shown = defaultState;
|
||||||
},
|
};
|
||||||
|
|
||||||
|
updateShownState();
|
||||||
|
|
||||||
|
const linkTypeRef = ref(props.link.type);
|
||||||
|
watch(linkTypeRef, () => {
|
||||||
|
updateShownState();
|
||||||
|
onPreviewUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
eventbus.on("resize", handleResize);
|
||||||
|
|
||||||
|
onPreviewUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
eventbus.off("resize", handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// Let this preview go through load/canplay events again,
|
||||||
|
// Otherwise the browser can cause a resize on video elements
|
||||||
|
props.link.sourceLoaded = false;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -9,12 +9,12 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {computed, defineComponent, PropType} from "vue";
|
import {computed, defineComponent, PropType} from "vue";
|
||||||
import {ClientMessage, LinkPreview} from "../js/types";
|
import {ClientMessage, ClientLinkPreview} from "../js/types";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "LinkPreviewToggle",
|
name: "LinkPreviewToggle",
|
||||||
props: {
|
props: {
|
||||||
link: {type: Object as PropType<LinkPreview>, required: true},
|
link: {type: Object as PropType<ClientLinkPreview>, required: true},
|
||||||
message: {type: Object as PropType<ClientMessage>, required: true},
|
message: {type: Object as PropType<ClientMessage>, required: true},
|
||||||
},
|
},
|
||||||
emits: ["toggle-link-preview"],
|
emits: ["toggle-link-preview"],
|
||||||
|
|
|
@ -78,7 +78,8 @@ import {
|
||||||
watch,
|
watch,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import {useStore} from "../js/store";
|
import {useStore} from "../js/store";
|
||||||
import type {ClientChan, ClientMessage, ClientNetwork, LinkPreview} from "../js/types";
|
import {ClientChan, ClientMessage, ClientNetwork, ClientLinkPreview} from "../js/types";
|
||||||
|
import Msg from "../../src/models/msg";
|
||||||
|
|
||||||
type CondensedMessageContainer = {
|
type CondensedMessageContainer = {
|
||||||
type: "condensed";
|
type: "condensed";
|
||||||
|
@ -107,8 +108,8 @@ export default defineComponent({
|
||||||
const historyObserver = ref<IntersectionObserver | null>(null);
|
const historyObserver = ref<IntersectionObserver | null>(null);
|
||||||
const skipNextScrollEvent = ref(false);
|
const skipNextScrollEvent = ref(false);
|
||||||
const unreadMarkerShown = ref(false);
|
const unreadMarkerShown = ref(false);
|
||||||
// TODO: make this a ref?
|
|
||||||
let isWaitingForNextTick = false;
|
const isWaitingForNextTick = ref(false);
|
||||||
|
|
||||||
const jumpToBottom = () => {
|
const jumpToBottom = () => {
|
||||||
skipNextScrollEvent.value = true;
|
skipNextScrollEvent.value = true;
|
||||||
|
@ -243,7 +244,7 @@ export default defineComponent({
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldDisplayDateMarker = (
|
const shouldDisplayDateMarker = (
|
||||||
message: ClientMessage | CondensedMessageContainer,
|
message: Msg | ClientMessage | CondensedMessageContainer,
|
||||||
id: number
|
id: number
|
||||||
) => {
|
) => {
|
||||||
const previousMessage = condensedMessages[id - 1];
|
const previousMessage = condensedMessages[id - 1];
|
||||||
|
@ -271,7 +272,7 @@ export default defineComponent({
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPreviousSource = (currentMessage: ClientMessage, id: number) => {
|
const isPreviousSource = (currentMessage: ClientMessage | Msg, id: number) => {
|
||||||
const previousMessage = condensedMessages[id - 1];
|
const previousMessage = condensedMessages[id - 1];
|
||||||
return !!(
|
return !!(
|
||||||
previousMessage &&
|
previousMessage &&
|
||||||
|
@ -291,7 +292,7 @@ export default defineComponent({
|
||||||
const keepScrollPosition = () => {
|
const keepScrollPosition = () => {
|
||||||
// If we are already waiting for the next tick to force scroll position,
|
// If we are already waiting for the next tick to force scroll position,
|
||||||
// we have no reason to perform more checks and set it again in the next tick
|
// we have no reason to perform more checks and set it again in the next tick
|
||||||
if (isWaitingForNextTick) {
|
if (isWaitingForNextTick.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -305,10 +306,10 @@ export default defineComponent({
|
||||||
if (props.channel.historyLoading) {
|
if (props.channel.historyLoading) {
|
||||||
const heightOld = el.scrollHeight - el.scrollTop;
|
const heightOld = el.scrollHeight - el.scrollTop;
|
||||||
|
|
||||||
isWaitingForNextTick = true;
|
isWaitingForNextTick.value = true;
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
isWaitingForNextTick = false;
|
isWaitingForNextTick.value = false;
|
||||||
skipNextScrollEvent.value = true;
|
skipNextScrollEvent.value = true;
|
||||||
el.scrollTop = el.scrollHeight - heightOld;
|
el.scrollTop = el.scrollHeight - heightOld;
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
|
@ -319,16 +320,16 @@ export default defineComponent({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isWaitingForNextTick = true;
|
isWaitingForNextTick.value = true;
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
isWaitingForNextTick = false;
|
isWaitingForNextTick.value = false;
|
||||||
jumpToBottom();
|
jumpToBottom();
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// no-op
|
// no-op
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLinkPreviewToggle = (preview: LinkPreview, message: ClientMessage) => {
|
const onLinkPreviewToggle = (preview: ClientLinkPreview, message: ClientMessage) => {
|
||||||
keepScrollPosition();
|
keepScrollPosition();
|
||||||
|
|
||||||
// Tell the server we're toggling so it remembers at page reload
|
// Tell the server we're toggling so it remembers at page reload
|
||||||
|
|
|
@ -199,7 +199,7 @@
|
||||||
import {computed, watch, defineComponent, nextTick, onBeforeUnmount, onMounted, ref} from "vue";
|
import {computed, watch, defineComponent, nextTick, onBeforeUnmount, onMounted, ref} from "vue";
|
||||||
|
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import Draggable from "vuedraggable";
|
import {VueDraggableNext} from "vue-draggable-next";
|
||||||
import {filter as fuzzyFilter} from "fuzzy";
|
import {filter as fuzzyFilter} from "fuzzy";
|
||||||
import NetworkLobby from "./NetworkLobby.vue";
|
import NetworkLobby from "./NetworkLobby.vue";
|
||||||
import Channel from "./Channel.vue";
|
import Channel from "./Channel.vue";
|
||||||
|
@ -220,7 +220,7 @@ export default defineComponent({
|
||||||
JoinChannel,
|
JoinChannel,
|
||||||
NetworkLobby,
|
NetworkLobby,
|
||||||
Channel,
|
Channel,
|
||||||
Draggable,
|
Draggable: VueDraggableNext,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
@ -280,7 +280,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (store.state.activeChannel) {
|
if (store.state.activeChannel) {
|
||||||
collapseNetwork(store.state.activeChannel.network, false);
|
collapseNetworkHelper(store.state.activeChannel.network, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
@ -533,7 +533,7 @@ export default defineComponent({
|
||||||
scrollToActive,
|
scrollToActive,
|
||||||
selectResult,
|
selectResult,
|
||||||
navigateResults,
|
navigateResults,
|
||||||
|
onChannelSort,
|
||||||
onNetworkSort,
|
onNetworkSort,
|
||||||
onDraggableTouchStart,
|
onDraggableTouchStart,
|
||||||
onDraggableTouchMove,
|
onDraggableTouchMove,
|
||||||
|
|
|
@ -12,17 +12,14 @@ export default defineComponent({
|
||||||
network: {type: Object as PropType<ClientNetwork>, required: false},
|
network: {type: Object as PropType<ClientNetwork>, required: false},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const render = () => {
|
//
|
||||||
return parse(
|
},
|
||||||
typeof props.text !== "undefined" ? props.text : props.message!.text,
|
render(context) {
|
||||||
props.message,
|
return parse(
|
||||||
props.network
|
typeof context.text !== "undefined" ? context.text : context.message.text,
|
||||||
);
|
context.message,
|
||||||
};
|
context.network
|
||||||
|
);
|
||||||
return {
|
|
||||||
render,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -161,7 +161,7 @@ export default defineComponent({
|
||||||
name: "NotificationSettings",
|
name: "NotificationSettings",
|
||||||
setup() {
|
setup() {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
console.log(store);
|
||||||
const isIOS = computed(
|
const isIOS = computed(
|
||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
|
|
|
@ -91,7 +91,7 @@ export default defineComponent({
|
||||||
NetworkList,
|
NetworkList,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
overlay: {type: Object as PropType<HTMLElement>, required: true},
|
overlay: {type: Object as PropType<HTMLElement | null>, required: true},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const isDevelopment = process.env.NODE_ENV !== "production";
|
const isDevelopment = process.env.NODE_ENV !== "production";
|
||||||
|
|
|
@ -1,11 +1,19 @@
|
||||||
<template>
|
<template>
|
||||||
<button class="lt" aria-label="Toggle channel list" @click="$store.commit('toggleSidebar')" />
|
<button class="lt" aria-label="Toggle channel list" @click="store.commit('toggleSidebar')" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {defineComponent} from "vue";
|
import {defineComponent} from "vue";
|
||||||
|
import {useStore} from "../js/store";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "SidebarToggle",
|
name: "SidebarToggle",
|
||||||
|
setup() {
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -160,11 +160,11 @@ router.afterEach((to) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// When switching out of a channel, mark everything as read
|
// When switching out of a channel, mark everything as read
|
||||||
if (channel.messages.length > 0) {
|
if (channel.messages?.length > 0) {
|
||||||
channel.firstUnread = channel.messages[channel.messages.length - 1].id;
|
channel.firstUnread = channel.messages[channel.messages.length - 1].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel.messages.length > 100) {
|
if (channel.messages?.length > 100) {
|
||||||
channel.messages.splice(0, channel.messages.length - 100);
|
channel.messages.splice(0, channel.messages.length - 100);
|
||||||
channel.moreHistoryAvailable = true;
|
channel.moreHistoryAvailable = true;
|
||||||
}
|
}
|
||||||
|
@ -172,7 +172,7 @@ router.afterEach((to) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function navigate(routeName: string, params: any = {}) {
|
function navigate(routeName: string, params: any = {}) {
|
||||||
if (router.currentRoute.name) {
|
if (router.currentRoute.value.name) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
router.push({name: routeName, params}).catch(() => {});
|
router.push({name: routeName, params}).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
|
|
15
client/js/types.d.ts
vendored
15
client/js/types.d.ts
vendored
|
@ -27,10 +27,15 @@ type ClientUser = User & {
|
||||||
//
|
//
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClientChan = Omit<Chan, "users"> & {
|
type ClientMessage = Message & {
|
||||||
|
time: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClientChan = Omit<Chan, "users" | "messages"> & {
|
||||||
moreHistoryAvailable: boolean;
|
moreHistoryAvailable: boolean;
|
||||||
editTopic: boolean;
|
editTopic: boolean;
|
||||||
users: ClientUser[];
|
users: ClientUser[];
|
||||||
|
messages: ClientMessage[];
|
||||||
|
|
||||||
// these are added in store/initChannel
|
// these are added in store/initChannel
|
||||||
pendingMessage: string;
|
pendingMessage: string;
|
||||||
|
@ -53,10 +58,6 @@ type ClientNetwork = Omit<Network, "channels"> & {
|
||||||
channels: ClientChan[];
|
channels: ClientChan[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClientMessage = Message & {
|
|
||||||
//
|
|
||||||
};
|
|
||||||
|
|
||||||
type NetChan = {
|
type NetChan = {
|
||||||
channel: ClientChan;
|
channel: ClientChan;
|
||||||
network: ClientNetwork;
|
network: ClientNetwork;
|
||||||
|
@ -68,7 +69,9 @@ type ClientMention = Mention & {
|
||||||
channel: NetChan | null;
|
channel: NetChan | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LinkPreview = LinkPreview;
|
type ClientLinkPreview = LinkPreview & {
|
||||||
|
sourceLoaded?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
declare module "*.vue" {
|
declare module "*.vue" {
|
||||||
const Component: ReturnType<typeof defineComponent>;
|
const Component: ReturnType<typeof defineComponent>;
|
||||||
|
|
|
@ -83,7 +83,3 @@ VueApp.config.errorHandler = function (e) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error(e);
|
console.error(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
VueApp.config.globalProperties = {
|
|
||||||
$store: store as TypedStore,
|
|
||||||
};
|
|
||||||
|
|
|
@ -28,7 +28,7 @@ export type LinkPreview = {
|
||||||
size: number;
|
size: number;
|
||||||
link: string; // Send original matched link to the client
|
link: string; // Send original matched link to the client
|
||||||
shown: boolean | null;
|
shown: boolean | null;
|
||||||
error: undefined | any;
|
error: undefined | string;
|
||||||
message: undefined | string;
|
message: undefined | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue