[NEW] Add activity indicators for Uploading and Recording using new API; Support thread context; Deprecate the old typing API (#22392)
parent
60b4759828
commit
a024900596
@ -1,20 +0,0 @@ |
||||
<template name="messageBoxTyping" args="rid"> |
||||
{{#with data}} |
||||
<div class="rc-message-box__typing"> |
||||
<span class="rc-message-box__typing-user">{{users}}</span> |
||||
{{#if multi}} |
||||
{{#if selfTyping}} |
||||
{{_ "are_also_typing"}} |
||||
{{else}} |
||||
{{_ "are_typing"}} |
||||
{{/if}} |
||||
{{else}} |
||||
{{#if selfTyping}} |
||||
{{_ "is_also_typing" context="male"}} |
||||
{{else}} |
||||
{{_ "is_typing" context="male"}} |
||||
{{/if}} |
||||
{{/if}} |
||||
</div> |
||||
{{/with}} |
||||
</template> |
||||
@ -1,35 +0,0 @@ |
||||
import { Template } from 'meteor/templating'; |
||||
|
||||
import { MsgTyping } from '../../../ui'; |
||||
import { t } from '../../../utils'; |
||||
import { getConfig } from '../../../../client/lib/utils/getConfig'; |
||||
import './messageBoxTyping.html'; |
||||
|
||||
const maxUsernames = parseInt(getConfig('max-usernames-typing')) || 4; |
||||
|
||||
Template.messageBoxTyping.helpers({ |
||||
data() { |
||||
const users = MsgTyping.get(this.rid); |
||||
if (users.length === 0) { |
||||
return; |
||||
} |
||||
if (users.length === 1) { |
||||
return { |
||||
multi: false, |
||||
selfTyping: MsgTyping.selfTyping, |
||||
users: users[0], |
||||
}; |
||||
} |
||||
let last = users.pop(); |
||||
if (users.length >= maxUsernames) { |
||||
last = t('others'); |
||||
} |
||||
let usernames = users.slice(0, maxUsernames - 1).join(', '); |
||||
usernames = [usernames, last]; |
||||
return { |
||||
multi: true, |
||||
selfTyping: MsgTyping.selfTyping, |
||||
users: usernames.join(` ${ t('and') } `), |
||||
}; |
||||
}, |
||||
}); |
||||
@ -0,0 +1,14 @@ |
||||
<template name="userActionIndicator" args="rid, tmid"> |
||||
{{#if data}} |
||||
<div class="rc-message-box__action"> |
||||
{{#each data}} |
||||
<span class="rc-message-box__action-user">{{ users }}</span> |
||||
{{#if multi}} |
||||
{{_ "are" }} {{_ action}}{{#unless end}},{{/unless}} |
||||
{{else}} |
||||
{{_ "is" }} {{_ action}}{{#unless end}},{{/unless}} |
||||
{{/if}} |
||||
{{/each}} |
||||
</div> |
||||
{{/if}} |
||||
</template> |
||||
@ -0,0 +1,59 @@ |
||||
import { Template } from 'meteor/templating'; |
||||
|
||||
import { UserAction } from '../../../ui'; |
||||
import { t } from '../../../utils/client'; |
||||
import { getConfig } from '../../../../client/lib/utils/getConfig'; |
||||
|
||||
import './userActionIndicator.html'; |
||||
|
||||
const maxUsernames = parseInt(getConfig('max-usernames-typing') || '2'); |
||||
|
||||
Template.userActionIndicator.helpers({ |
||||
data() { |
||||
const roomAction = UserAction.get(this.tmid || this.rid) || {}; |
||||
if (!Object.keys(roomAction).length) { |
||||
return []; |
||||
} |
||||
|
||||
const activities = Object.entries(roomAction); |
||||
const userActions = activities.map(([key, _users]) => { |
||||
const users = Object.keys(_users); |
||||
if (users.length === 0) { |
||||
return { |
||||
end: false, |
||||
}; |
||||
} |
||||
|
||||
const action = key.split('-')[1]; |
||||
if (users.length === 1) { |
||||
return { |
||||
action, |
||||
multi: false, |
||||
users: users[0], |
||||
end: false, |
||||
}; |
||||
} |
||||
|
||||
let last = users.pop(); |
||||
if (users.length >= maxUsernames) { |
||||
last = t('others'); |
||||
} |
||||
|
||||
const usernames = [users.slice(0, maxUsernames - 1).join(', '), last]; |
||||
return { |
||||
action, |
||||
multi: true, |
||||
users: usernames.join(` ${ t('and') } `), |
||||
end: false, |
||||
}; |
||||
}).filter((i) => i.action); |
||||
|
||||
if (!Object.keys(userActions).length) { |
||||
return []; |
||||
} |
||||
|
||||
// insert end=true for the last item.
|
||||
userActions[userActions.length - 1].end = true; |
||||
return userActions; |
||||
}, |
||||
}); |
||||
@ -0,0 +1,165 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import { ReactiveDict } from 'meteor/reactive-dict'; |
||||
import { Session } from 'meteor/session'; |
||||
import { debounce } from 'lodash'; |
||||
|
||||
import { settings } from '../../../settings/client'; |
||||
import { Notifications } from '../../../notifications/client'; |
||||
import { IExtras, IRoomActivity, IActionsObject, IUser } from '../../../../definition/IUserAction'; |
||||
|
||||
const TIMEOUT = 15000; |
||||
const RENEW = TIMEOUT / 3; |
||||
|
||||
export const USER_ACTIVITY = 'user-activity'; |
||||
|
||||
export const USER_ACTIVITIES = { |
||||
USER_RECORDING: 'user-recording', |
||||
USER_TYPING: 'user-typing', |
||||
USER_UPLOADING: 'user-uploading', |
||||
}; |
||||
|
||||
const activityTimeouts = new Map(); |
||||
const activityRenews = new Map(); |
||||
const continuingIntervals = new Map(); |
||||
const roomActivities = new Map<string, Set<string>>(); |
||||
const rooms = new Map<string, Function>(); |
||||
|
||||
const performingUsers = new ReactiveDict<IActionsObject>(); |
||||
|
||||
const shownName = function(user: IUser | null | undefined): string|undefined { |
||||
if (!user) { |
||||
return; |
||||
} |
||||
if (settings.get('UI_Use_Real_Name')) { |
||||
return user.name; |
||||
} |
||||
return user.username; |
||||
}; |
||||
|
||||
const emitActivities = debounce((rid: string, extras: IExtras): void => { |
||||
const activities = roomActivities.get(extras?.tmid || rid) || new Set(); |
||||
Notifications.notifyRoom(rid, USER_ACTIVITY, shownName(Meteor.user()), [...activities], extras); |
||||
}, 500); |
||||
|
||||
function handleStreamAction(rid: string, username: string, activityTypes: string[], extras?: IExtras): void { |
||||
rid = extras?.tmid || rid; |
||||
const roomActivities = performingUsers.get(rid) || {}; |
||||
|
||||
for (const [, activity] of Object.entries(USER_ACTIVITIES)) { |
||||
roomActivities[activity] = roomActivities[activity] || new Map(); |
||||
const users = roomActivities[activity]; |
||||
const timeout = users[username]; |
||||
|
||||
if (timeout) { |
||||
clearTimeout(timeout); |
||||
} |
||||
|
||||
if (activityTypes.includes(activity)) { |
||||
activityTypes.splice(activityTypes.indexOf(activity), 1); |
||||
users[username] = setTimeout(() => handleStreamAction(rid, username, activityTypes, extras), TIMEOUT); |
||||
} else { |
||||
delete users[username]; |
||||
} |
||||
} |
||||
|
||||
performingUsers.set(rid, roomActivities); |
||||
} |
||||
export const UserAction = new class { |
||||
constructor() { |
||||
Tracker.autorun(() => Session.get('openedRoom') && this.addStream(Session.get('openedRoom'))); |
||||
} |
||||
|
||||
addStream(rid: string): void { |
||||
if (rooms.get(rid)) { |
||||
return; |
||||
} |
||||
const handler = function(username: string, activityType: string[], extras?: object): void { |
||||
const user = Meteor.users.findOne(Meteor.userId() || undefined, { fields: { name: 1, username: 1 } }); |
||||
if (username === shownName(user)) { |
||||
return; |
||||
} |
||||
handleStreamAction(rid, username, activityType, extras); |
||||
}; |
||||
rooms.set(rid, handler); |
||||
Notifications.onRoom(rid, USER_ACTIVITY, handler); |
||||
} |
||||
|
||||
performContinuously(rid: string, activityType: string, extras: IExtras = {}): void { |
||||
const trid = extras?.tmid || rid; |
||||
const key = `${ activityType }-${ trid }`; |
||||
|
||||
if (continuingIntervals.get(key)) { |
||||
return; |
||||
} |
||||
this.start(rid, activityType, extras); |
||||
|
||||
continuingIntervals.set(key, setInterval(() => { |
||||
this.start(rid, activityType, extras); |
||||
}, RENEW)); |
||||
} |
||||
|
||||
start(rid: string, activityType: string, extras: IExtras = {}): void { |
||||
const trid = extras?.tmid || rid; |
||||
const key = `${ activityType }-${ trid }`; |
||||
|
||||
if (activityRenews.get(key)) { |
||||
return; |
||||
} |
||||
|
||||
activityRenews.set(key, setTimeout(() => { |
||||
clearTimeout(activityRenews.get(key)); |
||||
activityRenews.delete(key); |
||||
}, RENEW)); |
||||
|
||||
const activities = roomActivities.get(trid) || new Set(); |
||||
activities.add(activityType); |
||||
roomActivities.set(trid, activities); |
||||
|
||||
emitActivities(rid, extras); |
||||
|
||||
if (activityTimeouts.get(key)) { |
||||
clearTimeout(activityTimeouts.get(key)); |
||||
activityTimeouts.delete(key); |
||||
} |
||||
|
||||
activityTimeouts.set(key, setTimeout(() => this.stop(trid, activityType, extras), TIMEOUT)); |
||||
activityTimeouts.get(key); |
||||
} |
||||
|
||||
stop(rid: string, activityType: string, extras: IExtras): void { |
||||
const trid = extras?.tmid || rid; |
||||
const key = `${ activityType }-${ trid }`; |
||||
|
||||
if (activityTimeouts.get(key)) { |
||||
clearTimeout(activityTimeouts.get(key)); |
||||
activityTimeouts.delete(key); |
||||
} |
||||
if (activityRenews.get(key)) { |
||||
clearTimeout(activityRenews.get(key)); |
||||
activityRenews.delete(key); |
||||
} |
||||
if (continuingIntervals.get(key)) { |
||||
clearInterval(continuingIntervals.get(key)); |
||||
continuingIntervals.delete(key); |
||||
} |
||||
|
||||
const activities = roomActivities.get(trid) || new Set(); |
||||
activities.delete(activityType); |
||||
roomActivities.set(trid, activities); |
||||
emitActivities(rid, extras); |
||||
} |
||||
|
||||
cancel(rid: string): void { |
||||
if (!rooms.get(rid)) { |
||||
return; |
||||
} |
||||
|
||||
Notifications.unRoom(rid, USER_ACTIVITY, rooms.get(rid)); |
||||
rooms.delete(rid); |
||||
} |
||||
|
||||
get(roomId: string): IRoomActivity | undefined { |
||||
return performingUsers.get(roomId); |
||||
} |
||||
}(); |
||||
@ -1,109 +0,0 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import { ReactiveVar } from 'meteor/reactive-var'; |
||||
import { ReactiveDict } from 'meteor/reactive-dict'; |
||||
import { Session } from 'meteor/session'; |
||||
import _ from 'underscore'; |
||||
|
||||
import { settings } from '../../../settings'; |
||||
import { Notifications } from '../../../notifications'; |
||||
|
||||
const shownName = function(user) { |
||||
if (!user) { |
||||
return; |
||||
} |
||||
if (settings.get('UI_Use_Real_Name')) { |
||||
return user.name; |
||||
} |
||||
return user.username; |
||||
}; |
||||
|
||||
const timeouts = {}; |
||||
const timeout = 15000; |
||||
const renew = timeout / 3; |
||||
const renews = {}; |
||||
const rooms = {}; |
||||
const selfTyping = new ReactiveVar(false); |
||||
const usersTyping = new ReactiveDict(); |
||||
|
||||
const stopTyping = (rid) => Notifications.notifyRoom(rid, 'typing', shownName(Meteor.user()), false); |
||||
const typing = (rid) => Notifications.notifyRoom(rid, 'typing', shownName(Meteor.user()), true); |
||||
|
||||
export const MsgTyping = new class { |
||||
constructor() { |
||||
Tracker.autorun(() => Session.get('openedRoom') && this.addStream(Session.get('openedRoom'))); |
||||
} |
||||
|
||||
get selfTyping() { return selfTyping.get(); } |
||||
|
||||
cancel(rid) { |
||||
if (rooms[rid]) { |
||||
Notifications.unRoom(rid, 'typing', rooms[rid]); |
||||
Object.values(usersTyping.get(rid) || {}).forEach(clearTimeout); |
||||
usersTyping.set(rid); |
||||
delete rooms[rid]; |
||||
} |
||||
} |
||||
|
||||
addStream(rid) { |
||||
if (rooms[rid]) { |
||||
return; |
||||
} |
||||
rooms[rid] = function(username, typing) { |
||||
const user = Meteor.users.findOne(Meteor.userId(), { fields: { name: 1, username: 1 } }); |
||||
if (username === shownName(user)) { |
||||
return; |
||||
} |
||||
const users = usersTyping.get(rid) || {}; |
||||
if (typing === true) { |
||||
clearTimeout(users[username]); |
||||
users[username] = setTimeout(function() { |
||||
const u = usersTyping.get(rid); |
||||
delete u[username]; |
||||
usersTyping.set(rid, u); |
||||
}, timeout); |
||||
} else { |
||||
delete users[username]; |
||||
} |
||||
|
||||
usersTyping.set(rid, users); |
||||
}; |
||||
return Notifications.onRoom(rid, 'typing', rooms[rid]); |
||||
} |
||||
|
||||
stop(rid) { |
||||
selfTyping.set(false); |
||||
if (timeouts[rid]) { |
||||
clearTimeout(timeouts[rid]); |
||||
delete timeouts[rid]; |
||||
delete renews[rid]; |
||||
} |
||||
return stopTyping(rid); |
||||
} |
||||
|
||||
|
||||
start(rid) { |
||||
selfTyping.set(true); |
||||
|
||||
if (renews[rid]) { |
||||
return; |
||||
} |
||||
|
||||
renews[rid] = setTimeout(() => delete renews[rid], renew); |
||||
|
||||
typing(rid); |
||||
|
||||
if (timeouts[rid]) { |
||||
clearTimeout(timeouts[rid]); |
||||
} |
||||
|
||||
timeouts[rid] = setTimeout(() => this.stop(rid), timeout); |
||||
|
||||
return timeouts[rid]; |
||||
} |
||||
|
||||
|
||||
get(rid) { |
||||
return _.keys(usersTyping.get(rid)) || []; |
||||
} |
||||
}(); |
||||
@ -0,0 +1,15 @@ |
||||
export type IUser = { |
||||
_id: string; |
||||
username?: string; |
||||
name?: string; |
||||
} |
||||
|
||||
export type IExtras = { |
||||
tmid?: string; |
||||
} |
||||
|
||||
export type IActivity = Record<string, NodeJS.Timeout> |
||||
|
||||
export type IRoomActivity = Record<string, IActivity> |
||||
|
||||
export type IActionsObject = Record<string, IRoomActivity>; |
||||
Loading…
Reference in new issue