1
0
Fork 0
mirror of https://github.com/YunoHost-Apps/movim_ynh.git synced 2024-09-03 19:46:19 +02:00

update to movim upstream

This commit is contained in:
src386 2016-02-19 14:14:25 +01:00
parent de8ea29c5c
commit 882ecf654a
35 changed files with 444 additions and 114 deletions

View file

@ -1,5 +1,8 @@
**Changelog**
1.7 ?
- Update to movim 0.9 git2016-02-19
1.6.1 2016-02-12
- Update to movim 0.9 git2016-01-27
- Improve config/ and log/ protection (nginx)

View file

@ -5,7 +5,7 @@ Movim is a decentralized social network, written in PHP and HTML5 and based on t
It is recommended to use a "valid" certificate to use Movim, auto-signed is sometimes problematic. You might want to take a look a StartSSL or Let's Encrypt.
Provided Movim version : 0.9 git2016-02-12
Provided Movim version : 0.9 git2016-02-19
Please read CHANGELOG.

View file

@ -4,6 +4,8 @@ Movim Changelog
v0.9.1 (trunk)
---------------------------
* CSS fixes
* Add Last Message Edition support
* Improve Post discovery in the News page
v0.9
---------------------------

View file

@ -3,6 +3,7 @@
class BlogController extends BaseController {
function load() {
$this->session_only = false;
$this->public = true;
}
function dispatch() {

View file

@ -3,6 +3,7 @@
class NodeController extends BaseController {
function load() {
$this->session_only = false;
$this->public = true;
}
function dispatch() {

View file

@ -234,7 +234,11 @@ function explodeJid($jid)
if(isset($arr[1])) $resource = $arr[1];
else $resource = null;
list($username, $server) = explode('@', $jid);
$server = '';
$arr = explode('@', $jid);
$username = $arr[0];
if(isset($arr[1])) $server = $arr[1];
return array(
'username' => $username,
@ -373,3 +377,14 @@ function purifyHTML($string)
function firstLetterCapitalize($string) {
return ucfirst(strtolower(mb_substr($string, 0, 2)));
}
/**
* Truncates the given string at the specified length.
*
* @param string $str The input string.
* @param int $width The number of chars at which the string will be truncated.
* @return string
*/
function truncate($str, $width) {
return strtok(wordwrap($str, $width, "\n"), "\n");
}

View file

@ -3,6 +3,9 @@
namespace modl;
class Message extends Model {
public $id;
public $newid;
public $session;
public $jidto;
public $jidfrom;
@ -21,6 +24,7 @@ class Message extends Model {
public $color; // Only for chatroom purpose
public $publishedPrepared; // Only for chat purpose
public $edited;
public function __construct()
{
@ -28,6 +32,8 @@ class Message extends Model {
{
"session" :
{"type":"string", "size":128, "mandatory":true },
"id" :
{"type":"string", "size":36},
"jidto" :
{"type":"string", "size":128, "mandatory":true },
"jidfrom" :
@ -47,7 +53,9 @@ class Message extends Model {
"published" :
{"type":"date"},
"delivered" :
{"type":"date"}
{"type":"date"},
"edited" :
{"type":"int", "size":1}
}';
parent::__construct();
@ -59,6 +67,10 @@ class Message extends Model {
$jid = explode('/',(string)$stanza->attributes()->from);
$to = current(explode('/',(string)$stanza->attributes()->to));
if(isset($stanza->attributes()->id)) {
$this->id = (string)$stanza->attributes()->id;
}
// This is not very beautiful
$user = new \User;
$this->session = $user->getLogin();
@ -87,6 +99,12 @@ class Message extends Model {
// $this->html = \prepareString($this->body, false, $images);
//}
if($stanza->replace) {
$this->newid = $this->id;
$this->id = (string)$stanza->replace->attributes()->id;
$this->edited = true;
}
if($stanza->delay)
$this->published = gmdate('Y-m-d H:i:s', strtotime($stanza->delay->attributes()->stamp));
elseif($parent && $parent->delay)

View file

@ -5,43 +5,27 @@ namespace modl;
class MessageDAO extends SQL {
function set(Message $message) {
$this->_sql = '
insert into message
(
session,
jidto,
jidfrom,
resource,
type,
subject,
thread,
body,
html,
published,
delivered)
values(
:session,
:jidto,
:jidfrom,
:resource,
:type,
:subject,
:thread,
:body,
:html,
:published,
:delivered
)';
update message
set id = :thread,
body = :body,
html = :html,
published = :published,
delivered = :delivered,
edited = 1
where session = :session
and id = :id
and jidto = :jidto
and jidfrom = :jidfrom';
$this->prepare(
'Message',
array(
'thread' => $message->newid, // FIXME
'id' => $message->id,
'session' => $message->session,
'jidto' => $message->jidto,
'jidfrom' => $message->jidfrom,
'resource' => $message->resource,
'type' => $message->type,
'subject' => $message->subject,
'thread' => $message->thread,
'body' => $message->body,
'html' => $message->html,
'published' => $message->published,
@ -49,10 +33,85 @@ class MessageDAO extends SQL {
)
);
$this->run('Message');
if(!$this->_effective) {
$this->_sql = '
insert into message
(
id,
session,
jidto,
jidfrom,
resource,
type,
subject,
thread,
body,
html,
published,
delivered)
values(
:id,
:session,
:jidto,
:jidfrom,
:resource,
:type,
:subject,
:thread,
:body,
:html,
:published,
:delivered
)';
$this->prepare(
'Message',
array(
'id' => $message->id,
'session' => $message->session,
'jidto' => $message->jidto,
'jidfrom' => $message->jidfrom,
'resource' => $message->resource,
'type' => $message->type,
'subject' => $message->subject,
'thread' => $message->thread,
'body' => $message->body,
'html' => $message->html,
'published' => $message->published,
'delivered' => $message->delivered
)
);
}
return $this->run('Message');
}
function getContact($jid, $limitf = false, $limitr = false) {
function getLastItem($to)
{
$this->_sql = '
select * from message
where session = :session
and jidto = :jidto
and jidfrom = :jidfrom
order by published desc
limit 1';
$this->prepare(
'Message',
array(
'session' => $this->_user,
'jidto' => $to,
'jidfrom' => $this->_user
)
);
return $this->run('Message', 'item');
}
function getContact($jid, $limitf = false, $limitr = false)
{
$this->_sql = '
select * from message
where session = :session

View file

@ -123,7 +123,8 @@ class Postn extends Model {
else
$entry = $item;
$this->__set('origin', $from);
if($from != '')
$this->__set('origin', $from);
if($node)
$this->__set('node', $node);
@ -358,9 +359,9 @@ class Postn extends Model {
public function getPublicUrl()
{
if($this->isMicroblog()) {
return \Route::urlize('blog', array($this->origin));
return \Route::urlize('blog', array($this->origin, $this->nodeid));
} else {
return \Route::urlize('node', array($this->origin, $this->node));
return \Route::urlize('node', array($this->origin, $this->node, $this->nodeid));
}
}

View file

@ -577,6 +577,32 @@ class PostnDAO extends SQL {
return $this->run('Postn');
}
function getLastBlogPublic($limitf = false, $limitr = false)
{
$this->_sql = '
select * from postn
left outer join contact on postn.aid = contact.jid
left outer join privacy on postn.nodeid = privacy.pkey
where
node = \'urn:xmpp:microblog:0\'
and postn.origin not in (select jid from rosterlink where session = :origin)
and privacy.value = 1
order by published desc
';
if($limitr)
$this->_sql = $this->_sql.' limit '.$limitr.' offset '.$limitf;
$this->prepare(
'Postn',
array(
'origin' => $this->_user
)
);
return $this->run('ContactPostn');
}
function exist($id) {
$this->_sql = '
select count(*) from postn

View file

@ -1,5 +1,5 @@
<main>
<?php $this->widget('Header'); ?>
<?php $this->widget('Header');?>
<section>
<?php $this->widget('AdminLogin');?>
</section>

View file

@ -67,7 +67,7 @@ class Blog extends WidgetBase {
$description = stripTags($this->_messages[0]->contentcleaned);
if(!empty($description)) {
$this->description = $description;
$this->description = truncate($description, 100);
}
$attachements = $this->_messages[0]->getAttachements();

View file

@ -5,7 +5,7 @@
<span class="primary icon gray">
<i class="zmdi zmdi-edit"></i>
</span>
<span class="control icon">
<span class="control icon active">
<a
href="{$c->route('feed', array($contact->jid))}"
target="_blank"

View file

@ -10,6 +10,8 @@ use Moxl\Xec\Action\Muc\SetSubject;
use Respect\Validation\Validator;
use Ramsey\Uuid\Uuid;
class Chat extends WidgetBase
{
private $_pagination = 30;
@ -223,7 +225,7 @@ class Chat extends WidgetBase
* @param string $message
* @return void
*/
function ajaxSendMessage($to, $message, $muc = false, $resource = false) {
function ajaxSendMessage($to, $message, $muc = false, $resource = false, $replace = false) {
if($message == '')
return;
@ -232,6 +234,18 @@ class Chat extends WidgetBase
$m->jidto = echapJid($to);
$m->jidfrom = $this->user->getLogin();
if($replace != false) {
$m->newid = Uuid::uuid4();
$m->id = $replace->id;
$m->edited = true;
$m->published = $replace->published;
$m->delivered = $replace->delivered;
} else {
$m->id = Uuid::uuid4();
$m->published = gmdate('Y-m-d H:i:s');
$m->delivered = gmdate('Y-m-d H:i:s');
}
$session = \Sessionx::start();
$m->type = 'chat';
@ -250,10 +264,8 @@ class Chat extends WidgetBase
$m->jidfrom = $to;
}
$m->body = rawurldecode($message);
$m->body = trim(rawurldecode($message));
//$m->html = prepareString($m->body, false, true);
$m->published = gmdate('Y-m-d H:i:s');
$m->delivered = gmdate('Y-m-d H:i:s');
if($resource != false) {
$to = $to . '/' . $resource;
@ -265,6 +277,13 @@ class Chat extends WidgetBase
//$p->setHTML($m->html);
$p->setContent($m->body);
if($replace != false) {
$p->setId($m->newid);
$p->setReplace($m->id);
} else {
$p->setId($m->id);
}
if($muc) {
$p->setMuc();
}
@ -284,6 +303,37 @@ class Chat extends WidgetBase
}
}
/**
* @brief Send a correction message
*
* @param string $to
* @param string $message
* @return void
*/
function ajaxCorrect($to, $message)
{
$md = new \Modl\MessageDAO;
$m = $md->getLastItem($to);
if($m) {
$this->ajaxSendMessage($to, $message, false, false, $m);
}
}
/**
* @brief Get the last message sent
*
* @param string $to
* @return void
*/
function ajaxLast($to)
{
$md = new \Modl\MessageDAO;
$m = $md->getLastItem($to);
RPC::call('Chat.setTextarea', $m->body);
}
/**
* @brief Send a "composing" message
*

View file

@ -111,6 +111,10 @@
state = 0;
Chat.sendMessage(this.dataset.jid, {if="$muc"}true{else}false{/if});
return false;
} else if(event.keyCode == 38) {
Chat_ajaxLast(this.dataset.jid);
} else if(event.keyCode == 40) {
Chat.clearReplace();
} else {
{if="!$muc"}
if(state == 0 || state == 2) {

View file

@ -5,6 +5,7 @@ var Chat = {
previous: null,
date: null,
lastScroll: null,
edit: false,
addSmiley: function(element) {
var n = document.querySelector('#chat_textarea');
n.value = n.value + element.dataset.emoji;
@ -18,7 +19,12 @@ var Chat = {
n.value = "";
n.focus();
movim_textarea_autoheight(n);
Chat_ajaxSendMessage(jid, encodeURIComponent(text), muc);
if(Chat.edit) {
Chat.edit = false;
Chat_ajaxCorrect(jid, encodeURIComponent(text));
} else {
Chat_ajaxSendMessage(jid, encodeURIComponent(text), muc);
}
},
focus: function()
{
@ -26,8 +32,15 @@ var Chat = {
document.querySelector('#chat_textarea').focus();
}
},
appendTextarea: function(value)
setTextarea: function(value)
{
Chat.edit = true;
document.querySelector('#chat_textarea').value = value;
},
clearReplace: function()
{
Chat.edit = false;
document.querySelector('#chat_textarea').value = '';
},
notify : function(title, body, image)
{
@ -70,6 +83,7 @@ var Chat = {
for(var i = 0, len = messages.length; i < len; ++i ) {
Chat.appendMessage(messages[i], false);
}
Chat.edit = false;
}
},
appendMessage : function(message, prepend) {
@ -127,6 +141,12 @@ var Chat = {
id = message.jidfrom + '_conversation';
}
if(message.id != null) {
bubble.id = message.id;
if(message.newid != null)
bubble.id = message.newid;
}
if(message.body.match(/^\/me/)) {
bubble.querySelector('div.bubble').className = 'bubble quote';
message.body = message.body.substr(4);
@ -135,7 +155,12 @@ var Chat = {
if(bubble) {
bubble.querySelector('div.bubble > p').innerHTML = message.body.replace(/\r\n?|\n/g, '<br />');
bubble.querySelector('div.bubble > span.info').innerHTML = message.publishedPrepared;
var info = bubble.querySelector('div.bubble > span.info');
info.innerHTML = message.publishedPrepared;
if(message.edited) {
info.innerHTML = '<i class="zmdi zmdi-edit"></i> ' + info.innerHTML;
}
if(prepend) {
Chat.date = message.published;
@ -148,6 +173,10 @@ var Chat = {
var scrollDiff = discussion.scrollHeight - Chat.lastScroll;
discussion.scrollTop += scrollDiff;
Chat.lastScroll = discussion.scrollHeight;
} else if(message.edited) {
var elem = document.getElementById(message.id);
if(elem)
elem.parentElement.replaceChild(bubble, elem);
} else {
movim_append(id, bubble.outerHTML);
}

View file

@ -5,3 +5,4 @@
#chats_widget_list:empty ~ .placeholder {
display: block;
}

View file

@ -1,6 +1,12 @@
<div>
<a href="{$c->route('main')}" class="classic">
<span id="menu" class="icon"><i class="zmdi zmdi-home"></i></span>
</a>
<h2>{$c->__('page.about')}</h2>
<ul class="list middle">
<li>
<span class="primary icon gray active">
<a href="{$c->route('main')}">
<i class="zmdi zmdi-arrow-back"></i>
</a>
</span>
<p>{$c->__('page.about')}</p>
</li>
</ul>
</div>

View file

@ -1,9 +1,12 @@
<div>
<ul class="list middle">
<li>
<a href="{$c->route('main')}" class="active classic">
<span id="menu" class="icon"><i class="zmdi zmdi-home"></i></span>
</a>
<span class="primary icon active">
<a href="{$c->route('main')}">
<i class="zmdi zmdi-arrow-back"></i>
</a>
</span>
<p>{$c->__('page.administration')}</p>
</li>
</ul>
</div>

View file

@ -1,6 +1,12 @@
<div>
<a href="{$c->route('main')}" class="classic">
<span id="menu" class="icon"><i class="zmdi zmdi-home"></i></span>
</a>
<h2>{$c->__('page.administration')}</h2>
<ul class="list middle">
<li>
<span class="primary icon active">
<a href="{$c->route('main')}">
<i class="zmdi zmdi-arrow-back"></i>
</a>
</span>
<p>{$c->__('page.administration')}</p>
</li>
</ul>
</div>

View file

@ -144,25 +144,27 @@ var Notification = {
Notification.document_title_init = document.title;
MovimWebsocket.attach(function() {
if(typeof Favico != 'undefined') {
Notification.favicon = new Favico({
animation: 'none',
fontStyle: 'normal',
bgColor: '#FF5722'
});
}
if(typeof MovimWebsocket != 'undefined') {
MovimWebsocket.attach(function() {
if(typeof Favico != 'undefined') {
Notification.favicon = new Favico({
animation: 'none',
fontStyle: 'normal',
bgColor: '#FF5722'
});
}
if(typeof require !== 'undefined') {
var remote = require('remote');
Notification.electron = remote.getCurrentWindow();
}
if(typeof require !== 'undefined') {
var remote = require('remote');
Notification.electron = remote.getCurrentWindow();
}
Notification.document_title = Notification.document_title_init;
Notification.tab_counter1 = Notification.tab_counter2 = 0;
Notification_ajaxGet();
Notification.current(Notification.notifs_key);
});
Notification.document_title = Notification.document_title_init;
Notification.tab_counter1 = Notification.tab_counter2 = 0;
Notification_ajaxGet();
Notification.current(Notification.notifs_key);
});
}
document.onblur = function() {
Notification.focused = false;

View file

@ -165,7 +165,8 @@ class Post extends WidgetBase
$nd = new \modl\PostnDAO();
$view = $this->tpl();
$view->assign('posts', $nd->getLastPublished(0, 10));
$view->assign('blogs', $nd->getLastBlogPublic(0, 6));
$view->assign('posts', $nd->getLastPublished(0, 4));
return $view->draw('_post_empty', true);
}

View file

@ -38,7 +38,7 @@
{/if}
</p>
</li>
</div>
</ul>
{/if}
{if="($public && $post->isPublic()) || !$public"}
@ -239,8 +239,11 @@
</form>
</span>
<p class="line normal">
{$c->__('post.public')}
</p>
<p>
<a target="_blank" href="{$post->getPublicUrl()}">
{$c->__('post.public')}
{$c->__('post.public_url')}
</a>
</p>
</li>

View file

@ -1,6 +1,74 @@
<br />
<h2 class="thin">{$c->__('post.hot')}</h2>
<h4 class="gray">{$c->__('post.hot_text')}</h4><br />
<header>
<ul class="list middle">
<li>
<p>
{$c->__('post.hot')}
</p>
</li>
<li>
<span class="primary icon gray">
<i class="zmdi zmdi-account"></i>
</span>
<p>
<h4 class="gray">{$c->__('post.blog_last')}</h4>
</p>
</li>
</ul>
</header>
<ul class="list flex card shadow active">
{loop="$blogs"}
{$attachements = $value->getAttachements()}
<li
class="block condensed"
data-id="{$value->nodeid}"
data-server="{$value->origin}"
data-node="{$value->node}">
{$picture = $value->getPicture()}
{if="$picture != null"}
<span class="primary icon thumb" style="background-image: url({$picture});"></span>
{else}
{$url = $value->getContact()->getPhoto('l')}
{if="$url"}
<span class="primary icon thumb" style="background-image: url({$url});">
</span>
{else}
<span class="primary icon thumb color {$value->getContact()->jid|stringToColor}">
<i class="zmdi zmdi-account"></i>
</span>
{/if}
{/if}
<p class="line">
{if="isset($value->title)"}
{$value->title}
{else}
{$value->node}
{/if}
</p>
<p>
<a href="{$c->route('contact', $value->getContact()->jid)}">
<i class="zmdi zmdi-account"></i> {$value->getContact()->getTrueName()}
</a>
{$value->published|strtotime|prepareDate}
{if="$value->published != $value->updated"}<i class="zmdi zmdi-edit"></i>{/if}
</p>
<p>
{$value->contentcleaned|strip_tags}
</p>
</li>
{/loop}
</ul>
<ul class="list thick">
<li>
<span class="primary icon gray">
<i class="zmdi zmdi-pages"></i>
</span>
<p>
<h4 class="gray">{$c->__('post.hot_text')}</h4>
</p>
</li>
</ul>
<ul class="list flex card shadow active">
{loop="$posts"}
{if="!filter_var($value->origin, FILTER_VALIDATE_EMAIL)"}

View file

@ -9,10 +9,12 @@ hot_text = Posts recently published in Groups that you are not subscribed
new = New post
repost = This is a re-post from %s
repost_profile = See %s profile
blog_last = Public posts from users
public = Publish this post publicly?
public_yes = This post is now public
public_no = This post is now private
public_url = Public URL of this post
delete_title = Delete this post
delete_text = You are going to delete this post, please confirm your action

View file

@ -1,12 +1,3 @@
#roster form div {
min-height: 0;
top: 0;
}
#roster form div input {
padding-top: 2rem;
}
#roster ul#rosterlist > div > li {
min-height: 0;
}

View file

@ -23,6 +23,7 @@
"forxer/Gravatar": "~1.2",
"respect/validation": "1.0.*",
"ezyang/htmlpurifier": "^4.7"
"ezyang/htmlpurifier": "^4.7",
"ramsey/uuid": "^3.2"
}
}

View file

@ -62,7 +62,8 @@ $stdin_behaviour = function ($data) use (&$conn, $loop, &$buffer, &$connector, &
} elseif($msg->func == 'unregister') {
\Moxl\Stanza\Stream::end();
} elseif($msg->func == 'register') {
if(is_resource($conn->stream)) {
if(isset($conn)
&& is_resource($conn->stream)) {
$conn->stream->close();
}

View file

@ -15,6 +15,7 @@ class AjaxController extends BaseController
{
protected $funclist = array();
protected static $instance;
protected $widgetlist = array();
public function __construct()
{
@ -49,11 +50,20 @@ class AjaxController extends BaseController
return $buffer . "</script>\n";
}
/**
* Check if the widget is registered
*/
public function isRegistered($widget)
{
return array_key_exists($widget, $this->widgetlist);
}
/**
* Defines a new function.
*/
public function defun($widget, $funcname, array $params)
{
array_push($this->widgetlist, $widget);
$this->funclist[$widget.$funcname] = array(
'object' => $widget,
'funcname' => $funcname,

View file

@ -4,17 +4,11 @@ class BaseController {
public $name = 'main'; // The name of the current page
protected $session_only = false;// The page is protected by a session ?
protected $raw = false; // Display only the content ?
protected $public = false; // It's a public page
protected $page;
function __construct() {
$this->page = new TplPageBuilder();
$this->page->addScript('movim_hash.js');
$this->page->addScript('movim_utils.js');
$this->page->addScript('movim_base.js');
$this->page->addScript('movim_tpl.js');
$this->page->addScript('movim_websocket.js');
$this->page->addScript('movim_map.js');
$this->page->addScript('pako_inflate.js');
}
/**
@ -63,6 +57,17 @@ class BaseController {
}
function display() {
$this->page->addScript('movim_hash.js');
$this->page->addScript('movim_utils.js');
$this->page->addScript('movim_base.js');
if(!$this->public) {
$this->page->addScript('movim_tpl.js');
$this->page->addScript('movim_websocket.js');
$this->page->addScript('movim_map.js');
$this->page->addScript('pako_inflate.js');
}
if($this->session_only) {
$user = new User();
$content = new TplPageBuilder($user);

View file

@ -14,7 +14,6 @@ class TplPageBuilder
{
private $theme = 'movim';
private $_view = '';
private $_color = 'green';
private $title = '';
private $menu = array();
private $content = '';

View file

@ -56,19 +56,21 @@ class WidgetBase
// Put default widget init here.
$this->ajax = AjaxController::getInstance();
// Generating Ajax calls.
$refl = new ReflectionClass($this->name);
$meths = $refl->getMethods();
if(!$this->ajax->isRegistered($this->name)) {
// Generating Ajax calls.
$refl = new ReflectionClass($this->name);
$meths = $refl->getMethods();
foreach($meths as $method) {
if(preg_match('#^ajax#', $method->name)) {
$pars = $method->getParameters();
$params = array();
foreach($pars as $param) {
$params[] = $param->name;
foreach($meths as $method) {
if(preg_match('#^ajax#', $method->name)) {
$pars = $method->getParameters();
$params = array();
foreach($pars as $param) {
$params[] = $param->name;
}
$this->ajax->defun($this->name, $method->name, $params);
}
$this->ajax->defun($this->name, $method->name, $params);
}
}

View file

@ -19,7 +19,7 @@
}
main > header a,
.icon:not(.placeholder) a,
.icon:not(.placeholder):not(.active) a,
.color input {
color: white;
}

View file

@ -27,3 +27,16 @@ header.fixed + div {
margin-top: 7rem;
}
header ul:first-child > li:first-child {
text-align: center;
}
/* Specific forms in header */
header form > div:not(.clear):not(.control) {
min-height: 0;
top: 0;
}
header form > div > input:not([type=submit]) {
padding-top: 2rem;
}

View file

@ -68,6 +68,11 @@ ul.list li.subheader p {
padding-left: 2rem;
}
ul.list li .primary > a,
ul.list li .control > a {
display: block;
}
/* Truncated content */
ul.list li.subheader > p,
@ -276,8 +281,8 @@ ul li span.counter.bottom {
/* Bubble */
ul li div.bubble {
padding: 1rem 2rem;
border-radius: 0.25rem;
padding: 1.25rem 2rem 0.75rem;
border-radius: 0.5rem;
line-height: 2.75rem;
position: relative;
box-sizing: border-box;
@ -303,6 +308,7 @@ ul li.oppose div.bubble {
margin-right: 9rem;
float: right;
position: initial;
background-color: #f5f5f5;
}
ul li div.bubble span.info {
@ -341,6 +347,7 @@ ul li:not(.same) div.bubble:before {
ul li.oppose:not(.same) div.bubble:before {
left: calc(100% - 10.5rem);
top: 1.5rem;
border-top-color: #f5f5f5;
}
/* Icon */