diff --git a/css/main.css b/css/main.css index ea78566..0899ebe 100644 --- a/css/main.css +++ b/css/main.css @@ -287,6 +287,30 @@ body, html { background-color: #6c6; } +#channels ul.groupMembers +{ + list-style: none; + margin: 0; + padding: 0; +} + +#channels ul.groupMembers li > a > img +{ + width: 50px; + height: 50px; +} + +#channels ul.groupMembers li +{ + float: left; + padding-right: 0.5rem; +} + +#channels .clearingDiv +{ + clear: both; +} + a.blockedAvatar { display: inline-block; diff --git a/css/profile.css b/css/profile.css index 2296a6e..5e3dce4 100644 --- a/css/profile.css +++ b/css/profile.css @@ -55,6 +55,7 @@ margin: 16px 0px 0px 0px; } +#profile .profileButtons span.addToGroup div.v-select, #profile .profileButtons button.clickButton { margin-left: 1rem; margin-top: 16px; @@ -112,3 +113,13 @@ width: 100%; height: 10rem; } + +#profile span.addToGroup { + display: inline-block; + float: right; +} + +#profile span.addToGroup div.v-select { + display: inline-block; + width: 20em; +} diff --git a/localprefs.js b/localprefs.js index d4693b2..9005073 100644 --- a/localprefs.js +++ b/localprefs.js @@ -51,6 +51,10 @@ exports.getAutorefresh = function() { return (getPref('autorefresh', (typeof def exports.setAutorefresh = function(isOn) { setPref('autorefresh', (isOn ? 'true' : 'false')) } +exports.getUserGroups = function() { return JSON.parse(getPref('userGroups', JSON.stringify(defaultPrefs.userGroups || []))) } + +exports.setUserGroups = function(userGroupArray) { setPref('userGroups', JSON.stringify(userGroupArray)) } + exports.updateStateFromSettings = function() { // Update the running state to match the stored settings. SSB.hops = this.getHops() diff --git a/messages.json b/messages.json index fe14666..4ed95ca 100644 --- a/messages.json +++ b/messages.json @@ -43,6 +43,10 @@ }, "channels": { "title": "Channels", + "yourGroups": "Your groups", + "newGroupName": "New group name", + "addGroup": "Add group", + "confirmDeleteGroup": "Are you sure you want to delete the group \"{group}\"?", "favoriteChannels": "Favorite channels", "otherChannels": "Other channels", "showChannels": "Show:", @@ -66,6 +70,11 @@ "dbStatus": "DB status", "ebtStatus": "EBT status" }, + "group": { + "title": "Group \"{name}\"", + "blankFieldError": "Please provide a message to publish.", + "postNewMessage": "Post general public message" + }, "messagePreview": { "postPreview": "Post preview", "postMessage": "Post message" @@ -102,6 +111,10 @@ "saveProfile": "Save profile", "sendMessage": "Send message", "removeFeed": "Remove feed", + "groupDropdownPlaceholder": "Add to group", + "addToGroup": "Add to group", + "removeFromGroup": "Remove from group", + "chooseGroupFirst": "Please choose a group first", "following": "Following", "blocking": "Blocking", "lastXMessagesFor": "Last {count} messages for", @@ -287,6 +300,10 @@ }, "channels": { "title": "チャンネル", + "yourGroups": "あなたのグループ", + "newGroupName": "新しいグループ名", + "addGroup": "グループを追加", + "confirmDeleteGroup": "「{group}」という名前のグループを削除してもよろしいですか?", "favoriteChannels": "お気に入りのチャンネル", "otherChannels": "他のチャネル", "showChannels": "見るに:", @@ -310,6 +327,11 @@ "dbStatus": "データベースのステータス", "ebtStatus": "EBTのステータス" }, + "group": { + "title": "グループ「{name}」", + "blankFieldError": "公開するメッセージを入力してください。", + "postNewMessage": "一般公開メッセージを投稿する" + }, "messagePreview": { "postPreview": "プレビュー", "postMessage": "掲示します" @@ -346,6 +368,10 @@ "saveProfile": "プロファイルを保存", "sendMessage": "メッセージを送る", "removeFeed": "フィードを削除する", + "groupDropdownPlaceholder": "グループに追加", + "addToGroup": "グループに追加", + "removeFromGroup": "グループから削除", + "chooseGroupFirst": "最初にグループを選択してください", "following": "フォローする", "blocking": "ブロックする", "lastXMessagesFor": "最後の{count}つのメッセージ:", diff --git a/ui/browser.js b/ui/browser.js index f322fed..925d547 100644 --- a/ui/browser.js +++ b/ui/browser.js @@ -40,6 +40,7 @@ require('ssb-browser-core/core').init("/.ssb-lite", optionsForCore); const Profile = require('./profile')() const Notifications = require('./notifications')() const Channel = require('./channel')() + const Group = require('./group')() const Channels = require('./channels')() const Thread = require('./thread')() const Private = require('./private')(componentsState) @@ -54,6 +55,7 @@ require('ssb-browser-core/core').init("/.ssb-lite", optionsForCore); { name: 'public', path: '/public', component: Public }, { name: 'channels', path: '/channels', component: Channels }, { name: 'channel', path: '/channel/:channel', component: Channel, props: true }, + { name: 'group', path: '/group/:group', component: Group, props: true }, { name: 'thread', path: '/thread/:rootId', component: Thread, props: true }, { name: 'profile', path: '/profile/:feedId', component: Profile, props: true }, { name: 'notifications', path: '/notifications', component: Notifications }, diff --git a/ui/channels.js b/ui/channels.js index 1b7bc36..6012b44 100644 --- a/ui/channels.js +++ b/ui/channels.js @@ -1,11 +1,25 @@ module.exports = function (componentsState) { const pull = require('pull-stream') const localPrefs = require('../localprefs') + const userGroups = require('../usergroups') const { and, not, isPublic, type, channel, startFrom, paginate, descending, toCallback } = SSB.dbOperators return { template: `
+

{{ $t('channels.yourGroups') }}

+
+
+

{{ group.name }} X

+
    +
  • + +
  • +
+
+
+   +

{{ $t('channels.title') }}

{{ $t('channels.favoriteChannels') }}

@@ -24,7 +38,7 @@ module.exports = function (componentsState) {
  1. #{{ channel }}[ {{ count }} ] -
  2. +

{{ $t('channels.loading') }}

`, @@ -32,18 +46,89 @@ module.exports = function (componentsState) { data: function() { return { channels: [], - favoriteChannels: [], + favoriteChannels: [], + groups: [], + groupName: '', sortMode: "recent" } }, methods: { + groupMemberInfoCallback: function(err, groupId, members) { + for (g in this.groups) { + if (this.groups[g].id == groupId) { + this.groups[g].members = members + return + } + } + }, + + loadGroups: function() { + var self = this + userGroups.getGroups((err, groups) => { + for (g in groups) { + var found = false + var fetchMembers = true + for (l in self.groups) { + if (groups[g].id == self.groups[l].id) { + found = true + if (self.groups[l].members && self.groups[l].members.length > 0) { + // Already have member info. + groups[g].members = self.groups[l].members + fetchMembers = false + } + } + } + + if (!found) { + groups[g].members = [] + self.groups.push(groups[g]) + } + if (fetchMembers) { + userGroups.getMembers(groups[g].id, self.groupMemberInfoCallback) + } + } + + // Normally I'd like to do this with a temporary variable and storing to the data variable once at the end, but in this case we've got too many potential member callbacks - complexity would be too high. + const sortFunc = (new Intl.Collator()).compare + self.groups = self.groups.sort((a, b) => { return sortFunc(a.name, b.name) }) + }) + }, + + addGroup: function() { + var self = this + userGroups.addGroup({ name: this.groupName }, (err, groupId) => { + if (err) { + alert(err) + } else { + self.loadGroups() + self.groupName = '' + } + }) + }, + + deleteGroup: function(group) { + var self = this + if (confirm(this.$root.$t('channels.confirmDeleteGroup', { group: group.name }))) { + userGroups.deleteGroup(group.id, (err, success) => { + if (err) + alert(err) + else { + self.groups = [] + self.loadGroups() + } + }) + } + }, + load: function() { document.body.classList.add('refreshing') // Get favorite channels from preferences. this.favoriteChannels = localPrefs.getFavoriteChannels().map(x => x.replace(/^#+/, '')).sort(Intl.Collator().compare) + this.loadGroups() + console.time("channel list") const resultCallback = toCallback((err, answer) => { diff --git a/ui/group.js b/ui/group.js new file mode 100644 index 0000000..c40fec5 --- /dev/null +++ b/ui/group.js @@ -0,0 +1,151 @@ +module.exports = function () { + const pull = require('pull-stream') + const ssbMentions = require('ssb-mentions') + const { and, or, author, isPublic, type, descending, startFrom, paginate, toCallback } = SSB.dbOperators + const userGroups = require('../usergroups') + + return { + template: ` +
+

{{ $t('group.title', { name: groupName }) }}

+ + + + +

{{ $t('common.lastXMessages', { count: pageSize }) }} + +

+ + +

{{ $t('common.noMessages') }}

+

{{ $t('common.showingMessagesFrom') }} 1-{{ displayPageEnd }}
+ +

+ +
`, + + props: ['group'], + + data: function() { + return { + groupName: "Loading...", + groupMembers: [], + postMessageVisible: false, + postText: "", + offset: 0, + pageSize: 50, + displayPageEnd: 50, + autorefreshTimer: 0, + showPreview: false, + messages: [] + } + }, + + methods: { + loadMore: function() { + SSB.db.query( + and(or(...this.groupMembers.map(x => author(x))), isPublic(), type('post')), + descending(), + startFrom(this.offset), + paginate(this.pageSize), + toCallback((err, answer) => { + this.messages = this.messages.concat(answer.results) + this.displayPageEnd = this.offset + this.pageSize + this.offset += this.pageSize // If we go by result length and we have filtered out all messages, we can never get more. + }) + ) + }, + + render: function () { + this.loadMore() + }, + + onFileSelect: function(ev) { + var self = this + helpers.handleFileSelect(ev, false, (err, text) => { + self.postText += text + }) + }, + + closePreview: function() { + this.showPreview = false + }, + + onPost: function() { + if (!this.postMessageVisible) { + this.postMessageVisible = true + return + } + + this.postText = this.$refs.markdownEditor.getMarkdown() + + // Make sure the full post (including headers) is not larger than the 8KiB limit. + var postData = this.buildPostData() + if (JSON.stringify(postData).length > 8192) { + alert(this.$root.$t('common.postTooLarge')) + return + } + + if (this.postText == '') { + alert(this.$root.$t('channel.blankFieldError')) + return + } + + this.showPreview = true + }, + + buildPostData: function() { + var mentions = ssbMentions(this.postText) + + var postData = { type: 'post', text: this.postText, mentions: mentions } + + return postData + }, + + confirmPost: function() { + var self = this + + var postData = this.buildPostData() + + SSB.db.publish(postData, (err) => { + if (err) console.log(err) + + self.postText = "" + self.postMessageVisible = false + self.showPreview = false + + self.refresh() + }) + }, + + refresh: function() { + console.log("Refreshing") + this.messages = [] + this.offset = 0 + this.render() + } + }, + + created: function () { + document.title = this.$root.appTitle + " - " + this.$root.$t('group.title', { name: this.groupName }) + + var self = this + userGroups.getGroups((err, groups) => { + for (g in groups) { + if (groups[g].id == self.group) + self.groupName = groups[g].name + } + }) + userGroups.getMembers(self.group, (err, groupId, members) => { + self.groupMembers = members + this.render() + }) + }, + + watch: { + groupName: function(oldVal, newVal) { + document.title = this.$root.appTitle + " - " + this.$root.$t('group.title', { name: this.groupName }) + } + } + } +} diff --git a/ui/profile.js b/ui/profile.js index 1ac648e..10d95e8 100644 --- a/ui/profile.js +++ b/ui/profile.js @@ -2,6 +2,7 @@ module.exports = function () { const pull = require('pull-stream') const helpers = require('./helpers') const md = require('./markdown') + const userGroups = require('../usergroups') const { and, author, type, isPublic, startFrom, paginate, descending, toCallback } = SSB.dbOperators let initialState = function(self) { @@ -23,6 +24,10 @@ module.exports = function () { showImportKey: false, mnemonic: '', + group: '', + alreadyInGroup: false, + groups: [], + offset: 0 } } @@ -61,7 +66,12 @@ module.exports = function () { {{ $t('profile.sendMessage') }} - +
+ + + + +

{{ $t('profile.following') }}

@@ -153,6 +163,47 @@ module.exports = function () { }, methods: { + groupChange: function() { + var self = this + if (this.group && this.group.id && this.group.id != '') { + userGroups.getMembers(this.group.id, (err, groupId, members) => { + self.alreadyInGroup = (members.indexOf(this.feedId) >= 0) + }) + } + }, + + addToGroup: function() { + var self = this + if (!this.group || !this.group.id || this.group.id == '') { + alert(this.$root.$t('profile.chooseGroupFirst')) + return + } + userGroups.addMember(this.group.id, this.feedId, (err, success) => { + if (err) { + alert(err) + return + } + + self.alreadyInGroup = true + }) + }, + + removeFromGroup: function() { + var self = this + if (!this.group || !this.group.id || this.group.id == '') { + alert(this.$root.$t('profile.chooseGroupFirst')) + return + } + userGroups.removeMember(this.group.id, this.feedId, (err, success) => { + if (err) { + alert(err) + return + } + + self.alreadyInGroup = false + }) + }, + cacheImageURLForPreview: function(blobId, cb) { var self = this ++this.waitingForBlobURLs @@ -397,6 +448,13 @@ module.exports = function () { self.blocking = self.feedId != SSB.net.id && contacts.isBlocking(SSB.net.id, self.feedId) }) + userGroups.getGroups((err, groups) => { + if (groups) { + const sortFunc = (new Intl.Collator()).compare + self.groups = groups.sort((a, b) => { return sortFunc(a.name, b.name) }) + } + }) + document.body.classList.add('refreshing') console.time("latest 25 profile messages") diff --git a/usergroups.js b/usergroups.js new file mode 100644 index 0000000..c48ffd0 --- /dev/null +++ b/usergroups.js @@ -0,0 +1,106 @@ +const localPrefs = require("./localprefs") + +// Groups are stored as: +// { +// id: (String, with the format not to be relied upon outside of this module) +// name: 'Name of group', +// description: 'Description of group in Markdown format (optional)', +// members: [ +// '@id_of_a_user', +// '@id_of_another_user' +// ] +// } + +function getFullLocalGroupInfo() { + return localPrefs.getUserGroups() +} + +function setFullLocalGroupInfo(groups) { + localPrefs.setUserGroups(groups) +} + +// Gets the names and IDs of groups, but not their members, in case that becomes a more time-consuming search later. +// This is a little roundabout for the way it's implemented right now, but I expect it will yield performance benefits if/when we persist this to SSB. +exports.getGroups = function(cb) { + const groups = getFullLocalGroupInfo().map((x) => { return { name: x.name, id: x.id, description: x.description || '' } }) + + cb(null, groups) +} + +exports.addGroup = function(groupInfo, cb) { + if (!groupInfo.name || groupInfo.name.trim() == '') { + cb("Group must have a name") + } + + // Good enough for now, until we find a way to persist this. + // This is purely to make sure the API references things by ID instead of by name, so we can swap out the implementation later. + var id = (new Date()).getTime() + + var groups = getFullLocalGroupInfo() + var newGroup = { name: groupInfo.name, id: id, members: [] } + if (groupInfo.description) + newGroup.description = groupInfo.description + groups.push(newGroup) + setFullLocalGroupInfo(groups) + + cb(null, id) +} + +exports.updateGroup = function(groupId, groupInfo, cb) { + cb("Not supported yet") +} + +exports.deleteGroup = function(groupId, cb) { + var groups = getFullLocalGroupInfo() + groups = groups.filter((x) => { return x.id != groupId }) + setFullLocalGroupInfo(groups) + cb(null, true) +} + +exports.getMembers = function(groupId, cb) { + var groups = getFullLocalGroupInfo() + for (g in groups) + if (groups[g].id == groupId) { + cb(null, groupId, groups[g].members) + return + } + + cb("Group not found") +} + +exports.addMember = function(groupId, feedId, cb) { + var groups = getFullLocalGroupInfo() + for (g in groups) + if (groups[g].id == groupId) { + if (groups[g].members.indexOf(feedId) >= 0) { + cb("Group already has member") + return + } else { + groups[g].members.push(feedId) + setFullLocalGroupInfo(groups) + cb(null, true) + return + } + } + + cb("Group not found") +} + +exports.removeMember = function(groupId, feedId, cb) { + var groups = getFullLocalGroupInfo() + for (g in groups) + if (groups[g].id == groupId) { + if (groups[g].members.indexOf(feedId) < 0) { + cb("Group does not have member") + return + } else { + groups[g].members = groups[g].members.filter((x) => { return x != feedId }) + setFullLocalGroupInfo(groups) + cb(null, true) + return + } + } + + cb("Group not found") +} +